Add a Table that shows one Row per element, with sort and filter.

The collection keeps its own order. Header clicks and the popup sort one Column, and the checklist or a typed comparison decides which Rows stay visible.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-31 16:22:42 +01:00
co-authored by Cursor
parent ab158db3e7
commit 188a6f38a5
25 changed files with 4136 additions and 0 deletions
@@ -0,0 +1,136 @@
package de.neitzel.fx.view;
import java.beans.BeanInfo;
import java.beans.IntrospectionException;
import java.beans.Introspector;
import java.beans.PropertyDescriptor;
import java.lang.reflect.Method;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.lang.reflect.WildcardType;
import java.time.temporal.TemporalAccessor;
import java.time.temporal.TemporalAmount;
import java.util.Calendar;
import java.util.Date;
import java.util.List;
/**
* Reads an Attribute's declared type from a Model.
*/
final class Attributes {
/**
* Prevents instantiation.
*/
private Attributes() {
}
/**
* Returns the element type of a collection Attribute.
*
* @param model the Model that holds the collection
* @param name the collection Attribute
* @return the element class
*/
static Class<?> elementType(Object model, String name) {
Method reader = reader(model.getClass(), name);
Class<?> element = classArgument(reader.getGenericReturnType());
if (element == null) {
throw new IllegalArgumentException("No element type for \"" + name + "\"");
}
return element;
}
/**
* Returns the declared type of one Attribute.
*
* @param type the Model class
* @param name the Attribute
* @return the declared type
*/
static Class<?> type(Class<?> type, String name) {
return reader(type, name).getReturnType();
}
/**
* Returns whether a Column can use this declared type.
* <p>
* A string, number, boolean, enum, or date or time is simple. A type with its own
* Attributes is not.
*
* @param type the declared type
* @return {@code true} when the type is a simple value
*/
static boolean simple(Class<?> type) {
if (type == String.class || type == Boolean.class || type == boolean.class || type.isEnum()) {
return true;
}
if (type.isPrimitive()) {
return type != char.class && type != void.class;
}
if (Number.class.isAssignableFrom(type)) {
return true;
}
if (Date.class.isAssignableFrom(type) || Calendar.class.isAssignableFrom(type)) {
return true;
}
return TemporalAccessor.class.isAssignableFrom(type) || TemporalAmount.class.isAssignableFrom(type);
}
/**
* Returns the read method of a read-write Attribute.
*
* @param type the Model class
* @param name the Attribute
* @return the getter
*/
private static Method reader(Class<?> type, String name) {
for (PropertyDescriptor descriptor : descriptors(type)) {
if (descriptor.getName().equals(name)
&& descriptor.getReadMethod() != null
&& descriptor.getWriteMethod() != null) {
return descriptor.getReadMethod();
}
}
throw new IllegalArgumentException("No attribute \"" + name + "\"");
}
/**
* Returns the bean attributes declared on {@code type}.
*
* @param type the Model class
* @return the attributes
*/
private static List<PropertyDescriptor> descriptors(Class<?> type) {
try {
BeanInfo info = Introspector.getBeanInfo(type, Object.class);
return List.of(info.getPropertyDescriptors());
} catch (IntrospectionException failure) {
throw new IllegalStateException("Could not introspect " + type.getName(), failure);
}
}
/**
* Returns the single type argument of a collection type.
*
* @param type the generic return type
* @return the element class, or {@code null} when it cannot be read
*/
private static Class<?> classArgument(Type type) {
if (!(type instanceof ParameterizedType parameterized)
|| parameterized.getActualTypeArguments().length != 1) {
return null;
}
Type argument = parameterized.getActualTypeArguments()[0];
if (argument instanceof Class<?> raw) {
return raw;
}
if (argument instanceof WildcardType wildcard && wildcard.getUpperBounds().length == 1) {
Type bound = wildcard.getUpperBounds()[0];
if (bound instanceof Class<?> raw) {
return raw;
}
}
return null;
}
}
@@ -0,0 +1,56 @@
package de.neitzel.fx.view;
/**
* One Column of a {@link Table}.
* <p>
* The Column names the header the developer sees and the Attribute of the row element
* that the header, the Filter, and the Sort share.
*/
public class Column {
/**
* The header text.
*/
private String header;
/**
* The Attribute of the row element.
*/
private String attribute;
/**
* Sets the header text.
*
* @param header the header text
*/
public void setHeader(String header) {
this.header = header;
}
/**
* Returns the header text.
*
* @return the header text
*/
public String getHeader() {
return header;
}
/**
* Sets the Attribute name.
*
* @param attribute the Attribute of the row element
*/
public void setAttribute(String attribute) {
this.attribute = attribute;
}
/**
* Returns the Attribute name.
*
* @return the Attribute of the row element
*/
public String getAttribute() {
return attribute;
}
}
File diff suppressed because it is too large Load Diff
@@ -12,6 +12,9 @@ import java.net.URL;
*/ */
public final class ViewLoader { public final class ViewLoader {
/**
* Prevents instantiation.
*/
private ViewLoader() { private ViewLoader() {
} }
@@ -93,6 +96,7 @@ public final class ViewLoader {
for (BindingList bindingList : session.bindingLists()) { for (BindingList bindingList : session.bindingLists()) {
bindingList.apply(loader.getNamespace(), viewModel, controller, root, context); bindingList.apply(loader.getNamespace(), viewModel, controller, root, context);
} }
Table.openAll(root, viewModel, context);
return new LoadedView(root, viewModel); return new LoadedView(root, viewModel);
} catch (IOException failure) { } catch (IOException failure) {
throw new IllegalStateException("Could not load view " + fxml, failure); throw new IllegalStateException("Could not load view " + fxml, failure);
@@ -101,6 +105,13 @@ public final class ViewLoader {
} }
} }
/**
* Asks the container for the Controller class named by the document.
*
* @param context the container
* @param type the Controller class
* @return the Controller
*/
private static Object controller(ApplicationContext context, Class<?> type) { private static Object controller(ApplicationContext context, Class<?> type) {
return context.getComponent(type.asSubclass(Object.class)); return context.getComponent(type.asSubclass(Object.class));
} }
@@ -0,0 +1,295 @@
package de.neitzel.fx.view;
import de.neitzel.fx.view.component.VisitRow;
import de.neitzel.fx.view.fixture.Host;
import de.neitzel.fx.view.fixture.Visit;
import de.neitzel.injection.ApplicationContext;
import javafx.collections.ObservableList;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.CheckBox;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.TextField;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;
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.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* A Column checklist shows only the confirmed values.
*/
class FilterByChecklistTest {
/**
* Starts the JavaFX toolkit once for this class.
*/
@BeforeAll
static void startJavaFx() throws InterruptedException {
FxThread.start();
}
/**
* Confirming a subset hides the rest, and the same Row comes back when the Filter is cleared.
*/
@Test
void confirmingASubsetHidesTheOtherRows() throws Exception {
FxThread.onFx(() -> {
Visit main = new Visit("Main", "Berlin");
Visit oak = new Visit("Oak", "Bonn");
Host host = new Host();
host.setVisits(new ArrayList<>(List.of(main, oak)));
host.setSelectedVisit(oak);
LoadedView loaded = load(host);
Table table = table(loaded);
VisitRow oakRow = (VisitRow) body(table).getChildren().get(1);
openCity(table);
uncheck(table, "Bonn");
command(table, "Confirm").fire();
assertEquals(List.of("Main"), streets(table));
assertTrue(opener(table, 1).getStyleClass().contains("filtered"));
assertEquals(List.of("Main", "Oak"), streets(host.getVisits()));
assertSame(oak, host.getSelectedVisit());
assertFalse(body(table).getChildren().stream()
.anyMatch(node -> node.getStyleClass().contains(Table.SELECTED_STYLE)));
openCity(table);
command(table, "Clear filter").fire();
assertSame(oakRow, body(table).getChildren().get(1));
assertEquals(List.of("Main", "Oak"), streets(table));
assertFalse(opener(table, 1).getStyleClass().contains("filtered"));
});
}
/**
* All checked is no Filter. A later value is shown. None checked hides every Row.
*/
@Test
void allCheckedLeavesNoFilterAndNoneCheckedHidesEveryRow() throws Exception {
FxThread.onFx(() -> {
Visit main = new Visit("Main", "Berlin");
Host host = new Host();
host.setVisits(new ArrayList<>(List.of(main, new Visit("Oak", null))));
LoadedView loaded = load(host);
Table table = table(loaded);
openCity(table);
List<String> labels = checks(table).stream().map(CheckBox::getText).toList();
assertEquals(List.of("", "Berlin"), labels);
command(table, "Confirm").fire();
assertFalse(opener(table, 1).getStyleClass().contains("filtered"));
@SuppressWarnings("unchecked")
ObservableList<Visit> visits = (ObservableList<Visit>) loaded.property("visits").getValue();
visits.add(new Visit("Elm", "Hamburg"));
assertEquals(List.of("Main", "Oak", "Elm"), streets(table));
openCity(table);
for (CheckBox box : checks(table)) {
box.setSelected(false);
}
command(table, "Confirm").fire();
assertEquals(List.of(), streets(table));
assertTrue(opener(table, 1).getStyleClass().contains("filtered"));
visits.add(new Visit("Pine", "Kiel"));
assertEquals(List.of(), streets(table));
});
}
/**
* An edit that fails the Filter hides that Row and keeps the new value on the entity.
*/
@Test
void anEditThatFailsTheFilterHidesTheRow() throws Exception {
FxThread.onFx(() -> {
Visit main = new Visit("Main", "Berlin");
Host host = new Host();
host.setVisits(new ArrayList<>(List.of(main, new Visit("Oak", "Bonn"))));
Table table = table(load(host));
openCity(table);
uncheck(table, "Bonn");
command(table, "Confirm").fire();
TextField city = (TextField) body(table).getChildren().get(0).lookup("#cityField");
city.setText("Bonn");
assertEquals(List.of(), streets(table));
assertEquals("Bonn", main.getCity());
openCity(table);
assertTrue(checks(table).stream().anyMatch(box -> "Bonn".equals(box.getText())));
});
}
/**
* A click outside the popup keeps the previous Filter.
*/
@Test
void aClickOutsideKeepsThePreviousFilter() throws Exception {
FxThread.onFx(() -> {
Host host = new Host();
host.setVisits(new ArrayList<>(List.of(new Visit("Main", "Berlin"), new Visit("Oak", "Bonn"))));
Table table = table(load(host));
openCity(table);
uncheck(table, "Bonn");
table.fireEvent(click());
assertEquals(List.of("Main", "Oak"), streets(table));
assertFalse(popup(table).isVisible());
});
}
/**
* Loads the selection view, which names the same Columns.
*
* @param host the model
* @return the loaded view
*/
private static LoadedView load(Host host) {
URL fxml = FilterByChecklistTest.class.getResource("show-visits-selection.fxml");
assertNotNull(fxml);
return ViewLoader.load(fxml, host, new ApplicationContext("de.neitzel.fx.view.component"));
}
/**
* Returns the table.
*
* @param loaded the loaded view
* @return the table
*/
private static Table table(LoadedView loaded) {
return (Table) loaded.getRoot().lookup("#visitsTable");
}
/**
* Returns the body.
*
* @param table the table
* @return the body
*/
private static VBox body(Table table) {
ScrollPane scroll = (ScrollPane) table.lookup("." + Table.SCROLL_STYLE);
return (VBox) scroll.getContent();
}
/**
* Returns the opener of one Column.
*
* @param table the table
* @param index the Column index
* @return the opener
*/
private static javafx.scene.control.Button opener(Table table, int index) {
ScrollPane headerScroll = (ScrollPane) table.lookup(".table-header-scroll");
HBox header = (HBox) headerScroll.getContent();
return (javafx.scene.control.Button) header.getChildren().get(index).lookup("." + Table.OPENER_STYLE);
}
/**
* Opens the City popup.
*
* @param table the table
*/
private static void openCity(Table table) {
ScrollPane headerScroll = (ScrollPane) table.lookup(".table-header-scroll");
HBox header = (HBox) headerScroll.getContent();
((javafx.scene.control.Button) header.getChildren().get(1).lookup("." + Table.OPENER_STYLE)).fire();
}
/**
* Returns the popup.
*
* @param table the table
* @return the popup
*/
private static VBox popup(Table table) {
return (VBox) table.lookup("." + Table.POPUP_STYLE);
}
/**
* Returns the checklist boxes.
*
* @param table the table
* @return the boxes
*/
private static List<CheckBox> checks(Table table) {
List<CheckBox> boxes = new ArrayList<>();
for (Node node : popup(table).getChildren()) {
if (node instanceof VBox list) {
for (Node child : list.getChildren()) {
if (child instanceof CheckBox box) {
boxes.add(box);
}
}
}
}
return boxes;
}
/**
* Unchecks the box with the given text.
*
* @param table the table
* @param text the checklist text
*/
private static void uncheck(Table table, String text) {
for (CheckBox box : checks(table)) {
if (text.equals(box.getText())) {
box.setSelected(false);
}
}
}
/**
* Returns a popup command.
*
* @param table the table
* @param text the command text
* @return the button
*/
private static Button command(Table table, String text) {
return popup(table).getChildren().stream()
.filter(Button.class::isInstance)
.map(Button.class::cast)
.filter(button -> text.equals(button.getText()))
.findFirst()
.orElseThrow();
}
/**
* Returns the streets of the visible rows.
*
* @param table the table
* @return the streets
*/
private static List<String> streets(Table table) {
List<String> streets = new ArrayList<>();
for (Node node : body(table).getChildren()) {
streets.add(((TextField) node.lookup("#streetField")).getText());
}
return streets;
}
/**
* Returns the streets of the collection.
*
* @param visits the visits
* @return the streets
*/
private static List<String> streets(List<Visit> visits) {
return visits.stream().map(Visit::getStreet).toList();
}
/**
* Returns a primary click.
*
* @return the event
*/
private static MouseEvent click() {
return new MouseEvent(MouseEvent.MOUSE_CLICKED, 1, 1, 1, 1, MouseButton.PRIMARY, 1,
false, false, false, false, true, false, false, false, false, false, null);
}
}
@@ -0,0 +1,325 @@
package de.neitzel.fx.view;
import de.neitzel.fx.view.fixture.Host;
import de.neitzel.fx.view.fixture.Role;
import de.neitzel.fx.view.fixture.Visit;
import de.neitzel.injection.ApplicationContext;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.CheckBox;
import javafx.scene.control.ComboBox;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.TextField;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.net.URL;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
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.assertTrue;
/**
* A typed comparison replaces the checklist on that Column.
*/
class FilterByComparisonTest {
/**
* Starts the JavaFX toolkit once for this class.
*/
@BeforeAll
static void startJavaFx() throws InterruptedException {
FxThread.start();
}
/**
* A string comparison ignores case, and confirming the checklist replaces it.
*/
@Test
void aStringComparisonReplacesTheChecklist() throws Exception {
FxThread.onFx(() -> {
Visit berlin = new Visit("Main", "Berlin");
Visit bonn = new Visit("Oak", "Bonn");
Visit lower = new Visit("Elm", "berlin");
Host host = host(berlin, bonn, lower);
Table table = first(load(host));
open(table, 1);
operator(table).setValue("Contains");
value(table).setText("ER");
command(table, "Confirm").fire();
assertFalse(popup(table).isVisible());
assertEquals(List.of("Main", "Elm"), streets(table));
assertEquals(List.of("Main", "Oak", "Elm"), streets(host.getVisits()));
open(table, 1);
assertEquals("Contains", operator(table).getValue());
assertEquals("ER", value(table).getText());
operator(table).setValue(null);
value(table).setText("");
uncheck(table, "Berlin");
uncheck(table, "berlin");
command(table, "Confirm").fire();
assertEquals(List.of("Oak"), streets(table));
});
}
/**
* Starts with and ends with match the checklist text, ignoring case.
*/
@Test
void startsWithAndEndsWithMatchTheChecklistText() throws Exception {
FxThread.onFx(() -> {
Host host = host(new Visit("Main", "Berlin"), new Visit("Oak", "Bonn"));
Table table = first(load(host));
open(table, 1);
operator(table).setValue("Starts with");
value(table).setText("bo");
command(table, "Confirm").fire();
assertEquals(List.of("Oak"), streets(table));
open(table, 1);
operator(table).setValue("Ends with");
value(table).setText("LIN");
command(table, "Confirm").fire();
assertEquals(List.of("Main"), streets(table));
});
}
/**
* A number comparison excludes the boundary, and a missing value does not pass.
*/
@Test
void aNumberComparisonExcludesTheBoundary() throws Exception {
FxThread.onFx(() -> {
Visit five = visit("Five", 5, false, Role.MEMBER, LocalDate.of(2020, 1, 2));
Visit six = visit("Six", 6, true, Role.ADMIN, LocalDate.of(2020, 1, 1));
Visit missing = visit("Missing", null, false, null, null);
Host host = host(five, six, missing);
Table table = first(load(host));
open(table, 2);
operator(table).setValue("Greater than");
value(table).setText("5");
command(table, "Confirm").fire();
assertEquals(List.of("Six"), streets(table));
open(table, 2);
value(table).setText("abc");
command(table, "Confirm").fire();
assertTrue(popup(table).isVisible());
assertEquals(List.of("Six"), streets(table));
value(table).setText("");
command(table, "Confirm").fire();
assertTrue(popup(table).isVisible());
operator(table).setValue(null);
value(table).setText("5");
command(table, "Confirm").fire();
assertTrue(popup(table).isVisible());
assertEquals(List.of("Six"), streets(table));
});
}
/**
* Before and after exclude the named day.
*/
@Test
void beforeAndAfterExcludeTheNamedDay() throws Exception {
FxThread.onFx(() -> {
Visit earlier = visit("Early", 1, false, Role.MEMBER, LocalDate.of(2020, 1, 1));
Visit day = visit("Day", 1, false, Role.MEMBER, LocalDate.of(2020, 1, 2));
Host host = host(earlier, day);
Table table = first(load(host));
open(table, 5);
operator(table).setValue("Before");
value(table).setText("2020-01-02");
command(table, "Confirm").fire();
assertEquals(List.of("Early"), streets(table));
open(table, 5);
operator(table).setValue("After");
value(table).setText("2020-01-01");
command(table, "Confirm").fire();
assertEquals(List.of("Day"), streets(table));
open(table, 5);
value(table).setText("02.01.2020");
command(table, "Confirm").fire();
assertTrue(popup(table).isVisible());
assertEquals(List.of("Day"), streets(table));
});
}
/**
* A boolean and an enum have no comparison.
*/
@Test
void aBooleanAndAnEnumHaveNoComparison() throws Exception {
FxThread.onFx(() -> {
Host host = host(visit("Main", 1, true, Role.MEMBER, LocalDate.of(2020, 1, 1)));
Table table = first(load(host));
open(table, 3);
assertFalse(operator(table).isVisible());
assertFalse(value(table).isVisible());
open(table, 4);
assertFalse(operator(table).isVisible());
assertFalse(value(table).isVisible());
});
}
/**
* Returns a visit with the extra Attributes filled in.
*
* @param street the street
* @param rank the rank
* @param open whether the visit is open
* @param role the role
* @param day the day
* @return the visit
*/
private static Visit visit(String street, Integer rank, boolean open, Role role, LocalDate day) {
Visit visit = new Visit(street, "Berlin");
visit.setRank(rank);
visit.setOpen(open);
visit.setRole(role);
visit.setDay(day);
return visit;
}
/**
* Returns a host with those visits.
*
* @param visits the visits
* @return the host
*/
private static Host host(Visit... visits) {
Host host = new Host();
host.setVisits(new ArrayList<>(List.of(visits)));
return host;
}
/**
* Loads the two-table view.
*
* @param host the model
* @return the loaded view
*/
private static LoadedView load(Host host) {
URL fxml = FilterByComparisonTest.class.getResource("show-two-tables.fxml");
assertNotNull(fxml);
return ViewLoader.load(fxml, host, new ApplicationContext("de.neitzel.fx.view.component"));
}
/**
* Returns the first table.
*
* @param loaded the loaded view
* @return the table
*/
private static Table first(LoadedView loaded) {
return (Table) loaded.getRoot().lookup("#firstTable");
}
/**
* Opens one Column's popup.
*
* @param table the table
* @param index the Column index
*/
private static void open(Table table, int index) {
ScrollPane headerScroll = (ScrollPane) table.lookup(".table-header-scroll");
HBox header = (HBox) headerScroll.getContent();
((Button) header.getChildren().get(index).lookup("." + Table.OPENER_STYLE)).fire();
}
/**
* Returns the popup.
*
* @param table the table
* @return the popup
*/
private static VBox popup(Table table) {
return (VBox) table.lookup("." + Table.POPUP_STYLE);
}
/**
* Returns the comparison operator.
*
* @param table the table
* @return the operator
*/
@SuppressWarnings("unchecked")
private static ComboBox<String> operator(Table table) {
return (ComboBox<String>) popup(table).lookup(".column-comparison-operator");
}
/**
* Returns the comparison value.
*
* @param table the table
* @return the value
*/
private static TextField value(Table table) {
return (TextField) popup(table).lookup(".column-comparison-value");
}
/**
* Unchecks the checklist entry with the given text.
*
* @param table the table
* @param text the checklist text
*/
private static void uncheck(Table table, String text) {
for (Node node : popup(table).getChildren()) {
if (!(node instanceof VBox list)) {
continue;
}
for (Node child : list.getChildren()) {
if (child instanceof CheckBox box && text.equals(box.getText())) {
box.setSelected(false);
}
}
}
}
/**
* Returns a popup command.
*
* @param table the table
* @param text the command text
* @return the button
*/
private static Button command(Table table, String text) {
return popup(table).getChildren().stream()
.filter(Button.class::isInstance)
.map(Button.class::cast)
.filter(button -> text.equals(button.getText()))
.findFirst()
.orElseThrow();
}
/**
* Returns the streets of the visible rows.
*
* @param table the table
* @return the streets
*/
private static List<String> streets(Table table) {
ScrollPane scroll = (ScrollPane) table.lookup("." + Table.SCROLL_STYLE);
VBox body = (VBox) scroll.getContent();
List<String> streets = new ArrayList<>();
for (Node node : body.getChildren()) {
streets.add(((TextField) node.lookup("#streetField")).getText());
}
return streets;
}
/**
* Returns the streets of the collection.
*
* @param visits the visits
* @return the streets
*/
private static List<String> streets(List<Visit> visits) {
return visits.stream().map(Visit::getStreet).toList();
}
}
@@ -0,0 +1,88 @@
package de.neitzel.fx.view;
import javafx.application.Platform;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.fail;
/**
* Runs a check on the JavaFX application thread.
*/
final class FxThread {
/**
* Prevents instantiation.
*/
private FxThread() {
}
/**
* Starts the JavaFX toolkit once.
*
* @throws InterruptedException when the toolkit does not start
*/
static void start() 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");
}
}
/**
* Runs one check on the JavaFX application thread.
*
* @param action the check
* @throws Exception when the check fails
*/
static void onFx(FxAction action) throws Exception {
if (Platform.isFxApplicationThread()) {
action.run();
return;
}
AtomicReference<Throwable> 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);
}
}
/**
* One check that runs on the JavaFX application thread.
*/
@FunctionalInterface
interface FxAction {
/**
* Runs the check.
*
* @throws Exception when the check fails
*/
void run() throws Exception;
}
}
@@ -0,0 +1,141 @@
package de.neitzel.fx.view;
import de.neitzel.fx.view.fixture.Host;
import de.neitzel.fx.view.fixture.Visit;
import de.neitzel.injection.ApplicationContext;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Region;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
/**
* Dragging a Column boundary changes that Column's width only.
*/
class ResizeAColumnTest {
/**
* Starts the JavaFX toolkit once for this class.
*/
@BeforeAll
static void startJavaFx() throws InterruptedException {
FxThread.start();
}
/**
* A drag widens one Column and leaves the other, the Sort, and the order alone.
*/
@Test
void aDragChangesOnlyThatColumn() throws Exception {
FxThread.onFx(() -> {
Host host = new Host();
host.setVisits(new ArrayList<>(List.of(new Visit("Main", "Berlin"))));
Table table = table(host);
HBox header = header(table);
Region street = (Region) header.getChildren().get(0);
Region city = (Region) header.getChildren().get(1);
assertEquals(150, street.getPrefWidth());
assertEquals(150, city.getPrefWidth());
drag(street.lookup("." + Table.RESIZE_STYLE), 100, 140);
assertEquals(190, street.getPrefWidth());
assertEquals(150, city.getPrefWidth());
double widened = street.getPrefWidth();
street.lookup("." + Table.RESIZE_STYLE).fireEvent(click(2));
assertEquals(widened, street.getPrefWidth());
table.setPrefWidth(800);
assertEquals(widened, street.getPrefWidth());
assertEquals(150, city.getPrefWidth());
assertEquals("Street", ((Label) street.lookup("." + Table.HEADER_TEXT_STYLE)).getText());
assertEquals("City", ((Label) city.lookup("." + Table.HEADER_TEXT_STYLE)).getText());
Label mark = (Label) street.lookup("." + Table.SORT_MARK_STYLE);
assertFalse(mark.isVisible());
});
}
/**
* Columns wider than the Table make the header wider than the viewport.
*/
@Test
void wideColumnsScrollHorizontally() throws Exception {
FxThread.onFx(() -> {
Host host = new Host();
host.setVisits(new ArrayList<>(List.of(new Visit("Main", "Berlin"))));
Table table = table(host);
HBox header = header(table);
drag(((Region) header.getChildren().get(0)).lookup("." + Table.RESIZE_STYLE), 0, 400);
assertEquals(700, header.getPrefWidth());
ScrollPane scroll = (ScrollPane) table.lookup("." + Table.SCROLL_STYLE);
assertEquals(javafx.scene.control.ScrollPane.ScrollBarPolicy.AS_NEEDED, scroll.getHbarPolicy());
});
}
/**
* Loads the visits table.
*
* @param host the model
* @return the table
*/
private static Table table(Host host) {
URL fxml = ResizeAColumnTest.class.getResource("show-visits.fxml");
assertNotNull(fxml);
LoadedView loaded = ViewLoader.load(fxml, host, new ApplicationContext("de.neitzel.fx.view.component"));
return (Table) loaded.getRoot().lookup("#visitsTable");
}
/**
* Returns the header row.
*
* @param table the table
* @return the header
*/
private static HBox header(Table table) {
ScrollPane headerScroll = (ScrollPane) table.lookup(".table-header-scroll");
return (HBox) headerScroll.getContent();
}
/**
* Drags a resize handle from one screen x to another.
*
* @param handle the handle
* @param from the start screen x
* @param to the end screen x
*/
private static void drag(javafx.scene.Node handle, double from, double to) {
handle.fireEvent(mouse(MouseEvent.MOUSE_PRESSED, from, 1));
handle.fireEvent(mouse(MouseEvent.MOUSE_DRAGGED, to, 1));
}
/**
* Returns a primary click.
*
* @param clicks the click count
* @return the event
*/
private static MouseEvent click(int clicks) {
return mouse(MouseEvent.MOUSE_CLICKED, 0, clicks);
}
/**
* Returns a primary mouse event.
*
* @param type the event type
* @param screen the screen x
* @param clicks the click count
* @return the event
*/
private static MouseEvent mouse(javafx.event.EventType<MouseEvent> type, double screen, int clicks) {
return new MouseEvent(type, screen, 0, screen, 0, MouseButton.PRIMARY, clicks,
false, false, false, false, true, false, false, false, false, false, null);
}
}
@@ -0,0 +1,241 @@
package de.neitzel.fx.view;
import de.neitzel.fx.view.fixture.Host;
import de.neitzel.fx.view.fixture.Visit;
import de.neitzel.injection.ApplicationContext;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.CheckBox;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.TextField;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;
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.assertTrue;
/**
* The checklist search narrows entries and Select All still reaches the hidden ones.
*/
class SearchTheChecklistTest {
/**
* Starts the JavaFX toolkit once for this class.
*/
@BeforeAll
static void startJavaFx() throws InterruptedException {
FxThread.start();
}
/**
* Typing narrows the list and leaves the checks and the Rows alone.
*/
@Test
void typingNarrowsTheChecklistWithoutChangingTheRows() throws Exception {
FxThread.onFx(() -> {
Host host = host(new Visit("Main", "Berlin"), new Visit("Oak", "Bonn"), new Visit("Elm", "Bern"));
Table table = table(load(host));
openCity(table);
search(table).setText("ber");
assertTrue(box(table, "Berlin").isVisible());
assertTrue(box(table, "Bern").isVisible());
assertFalse(box(table, "Bonn").isVisible());
assertTrue(box(table, "Bonn").isSelected());
assertEquals(List.of("Main", "Oak", "Elm"), streets(table));
search(table).setText("*");
assertFalse(box(table, "Berlin").isVisible());
assertEquals(List.of("Main", "Oak", "Elm"), streets(table));
});
}
/**
* Each opening starts with an empty search, so every entry is visible.
*/
@Test
void eachOpeningStartsWithAnEmptySearch() throws Exception {
FxThread.onFx(() -> {
Host host = host(new Visit("Main", "Berlin"), new Visit("Oak", "Bonn"));
Table table = table(load(host));
openCity(table);
search(table).setText("ber");
command(table, "Confirm").fire();
openCity(table);
assertEquals("", search(table).getText());
assertTrue(box(table, "Berlin").isVisible());
assertTrue(box(table, "Bonn").isVisible());
});
}
/**
* Select All reaches values the search box is hiding, and shows a mixed mark from the whole Column.
*/
@Test
void selectAllReachesValuesTheSearchIsHiding() throws Exception {
FxThread.onFx(() -> {
Host host = host(new Visit("Main", "Berlin"), new Visit("Oak", "Bonn"), new Visit("Elm", "Bern"));
Table table = table(load(host));
openCity(table);
box(table, "Bonn").setSelected(false);
search(table).setText("ber");
assertFalse(box(table, "Bonn").isVisible());
assertTrue(selectAll(table).isIndeterminate());
assertFalse(selectAll(table).isSelected());
click(selectAll(table));
assertTrue(box(table, "Bonn").isSelected());
search(table).setText("");
assertTrue(box(table, "Bonn").isSelected());
search(table).setText("ber");
click(selectAll(table));
assertFalse(box(table, "Bonn").isSelected());
assertFalse(box(table, "Berlin").isSelected());
});
}
/**
* Loads the visits table.
*
* @param host the model
* @return the loaded view
*/
private static LoadedView load(Host host) {
URL fxml = SearchTheChecklistTest.class.getResource("show-visits.fxml");
assertNotNull(fxml);
return ViewLoader.load(fxml, host, new ApplicationContext("de.neitzel.fx.view.component"));
}
/**
* Returns a host with those visits.
*
* @param visits the visits
* @return the host
*/
private static Host host(Visit... visits) {
Host host = new Host();
host.setVisits(new ArrayList<>(List.of(visits)));
return host;
}
/**
* Returns the table.
*
* @param loaded the loaded view
* @return the table
*/
private static Table table(LoadedView loaded) {
return (Table) loaded.getRoot().lookup("#visitsTable");
}
/**
* Opens the City popup.
*
* @param table the table
*/
private static void openCity(Table table) {
ScrollPane headerScroll = (ScrollPane) table.lookup(".table-header-scroll");
HBox header = (HBox) headerScroll.getContent();
((Button) header.getChildren().get(1).lookup("." + Table.OPENER_STYLE)).fire();
}
/**
* Returns the popup.
*
* @param table the table
* @return the popup
*/
private static VBox popup(Table table) {
return (VBox) table.lookup("." + Table.POPUP_STYLE);
}
/**
* Returns the search box.
*
* @param table the table
* @return the search box
*/
private static TextField search(Table table) {
return (TextField) popup(table).lookup(".column-search");
}
/**
* Returns Select All.
*
* @param table the table
* @return Select All
*/
private static CheckBox selectAll(Table table) {
return (CheckBox) popup(table).lookup(".column-select-all");
}
/**
* Returns the checklist box with the given text.
*
* @param table the table
* @param text the checklist text
* @return the box
*/
private static CheckBox box(Table table, String text) {
for (Node node : popup(table).getChildren()) {
if (!(node instanceof VBox list)) {
continue;
}
for (Node child : list.getChildren()) {
if (child instanceof CheckBox check && text.equals(check.getText())) {
return check;
}
}
}
throw new AssertionError("No checklist entry " + text);
}
/**
* Returns a popup command.
*
* @param table the table
* @param text the command text
* @return the button
*/
private static Button command(Table table, String text) {
return popup(table).getChildren().stream()
.filter(Button.class::isInstance)
.map(Button.class::cast)
.filter(button -> text.equals(button.getText()))
.findFirst()
.orElseThrow();
}
/**
* Returns the streets of the visible rows.
*
* @param table the table
* @return the streets
*/
private static List<String> streets(Table table) {
ScrollPane scroll = (ScrollPane) table.lookup("." + Table.SCROLL_STYLE);
VBox body = (VBox) scroll.getContent();
List<String> streets = new ArrayList<>();
for (Node node : body.getChildren()) {
streets.add(((TextField) node.lookup("#streetField")).getText());
}
return streets;
}
/**
* Clicks a node with the primary button.
*
* @param node the node
*/
private static void click(Node node) {
node.fireEvent(new MouseEvent(MouseEvent.MOUSE_CLICKED, 1, 1, 1, 1, MouseButton.PRIMARY, 1,
false, false, false, false, true, false, false, false, false, false, null));
}
}
@@ -0,0 +1,134 @@
package de.neitzel.fx.view;
import de.neitzel.fx.view.component.VisitRow;
import de.neitzel.fx.view.fixture.Host;
import de.neitzel.fx.view.fixture.Visit;
import de.neitzel.injection.ApplicationContext;
import javafx.scene.control.ScrollPane;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.VBox;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;
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;
/**
* Clicking a Row sets the parent Model's Selection.
*/
class SelectARowTest {
/**
* Starts the JavaFX toolkit once for this class.
*/
@BeforeAll
static void startJavaFx() throws InterruptedException {
FxThread.start();
}
/**
* A click selects the visit, including a click in a field, and a second click leaves it.
*/
@Test
void aClickSelectsTheVisit() throws Exception {
FxThread.onFx(() -> {
Visit main = new Visit("Main", "Berlin");
Visit oak = new Visit("Oak", "Bonn");
Host host = host(oak, main, oak);
LoadedView loaded = load(host);
VBox body = body(loaded);
VisitRow mainRow = (VisitRow) body.getChildren().get(0);
VisitRow oakRow = (VisitRow) body.getChildren().get(1);
assertTrue(oakRow.getStyleClass().contains(Table.SELECTED_STYLE));
assertFalse(mainRow.getStyleClass().contains(Table.SELECTED_STYLE));
click(mainRow.lookup("#streetField"));
assertSame(main, host.getSelectedVisit());
assertTrue(mainRow.getStyleClass().contains(Table.SELECTED_STYLE));
assertFalse(oakRow.getStyleClass().contains(Table.SELECTED_STYLE));
click(mainRow);
assertSame(main, host.getSelectedVisit());
});
}
/**
* A click on the blank body clears Selection. Removing the element clears it too.
*/
@Test
void theBlankBodyAndRemovalClearSelection() throws Exception {
FxThread.onFx(() -> {
Visit main = new Visit("Main", "Berlin");
Visit oak = new Visit("Oak", "Bonn");
Host host = host(main, main, oak);
LoadedView loaded = load(host);
VBox rows = body(loaded);
click(rows);
assertNull(host.getSelectedVisit());
assertFalse(((VisitRow) rows.getChildren().get(0)).getStyleClass().contains(Table.SELECTED_STYLE));
click(rows.getChildren().get(1));
assertSame(oak, host.getSelectedVisit());
@SuppressWarnings("unchecked")
javafx.collections.ObservableList<Visit> visits =
(javafx.collections.ObservableList<Visit>) loaded.property("visits").getValue();
visits.remove(oak);
assertNull(host.getSelectedVisit());
assertEquals(1, rows.getChildren().size());
});
}
/**
* Returns a host with the selection already set.
*
* @param selected the selected visit
* @param visits the visits
* @return the host
*/
private static Host host(Visit selected, Visit... visits) {
Host host = new Host();
host.setVisits(new ArrayList<>(List.of(visits)));
host.setSelectedVisit(selected);
return host;
}
/**
* Loads the selection view.
*
* @param host the model
* @return the loaded view
*/
private static LoadedView load(Host host) {
URL fxml = SelectARowTest.class.getResource("show-visits-selection.fxml");
assertNotNull(fxml);
return ViewLoader.load(fxml, host, new ApplicationContext("de.neitzel.fx.view.component"));
}
/**
* Returns the row body.
*
* @param loaded the loaded view
* @return the body
*/
private static VBox body(LoadedView loaded) {
Table table = (Table) loaded.getRoot().lookup("#visitsTable");
ScrollPane scroll = (ScrollPane) table.lookup("." + Table.SCROLL_STYLE);
return (VBox) scroll.getContent();
}
/**
* Clicks a node with the primary button.
*
* @param node the node
*/
private static void click(javafx.scene.Node node) {
node.fireEvent(new MouseEvent(MouseEvent.MOUSE_CLICKED, 1, 1, 1, 1, MouseButton.PRIMARY, 1,
false, false, false, false, true, false, false, false, false, false, null));
}
}
@@ -0,0 +1,360 @@
package de.neitzel.fx.view;
import de.neitzel.fx.view.component.CityFirstRow;
import de.neitzel.fx.view.component.VisitRow;
import de.neitzel.fx.view.fixture.Host;
import de.neitzel.fx.view.fixture.Visit;
import de.neitzel.injection.ApplicationContext;
import javafx.application.Platform;
import javafx.collections.ObservableList;
import javafx.scene.Parent;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.TextField;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;
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.assertInstanceOf;
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.assertThrows;
import static org.junit.jupiter.api.Assertions.fail;
/**
* A Table shows one Row for each element, in collection order.
*/
class ShowATableOfRowsTest {
/**
* Starts the JavaFX toolkit once for this class.
*/
@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");
}
}
/**
* Each visit appears in collection order, with its fields under the headers.
*/
@Test
void eachVisitAppearsInCollectionOrderUnderTheHeaders() throws Exception {
onFx(() -> {
Visit main = new Visit("Main", "Berlin");
Visit oak = new Visit("Oak", "Bonn");
Host host = host(main, oak);
LoadedView loaded = load("show-visits.fxml", host);
Table table = table(loaded);
HBox header = header(table);
assertEquals("Street", headerText(header, 0));
assertEquals("City", headerText(header, 1));
VBox body = body(table);
assertEquals(2, body.getChildren().size());
VisitRow first = (VisitRow) body.getChildren().get(0);
VisitRow second = (VisitRow) body.getChildren().get(1);
assertSame(main, first.getModel());
assertSame(oak, second.getModel());
assertEquals("Main", field(first, "streetField").getText());
assertEquals("Berlin", field(first, "cityField").getText());
assertEquals("Oak", field(second, "streetField").getText());
assertEquals("Bonn", field(second, "cityField").getText());
assertEquals(150, field(first, "streetField").getPrefWidth());
assertEquals(150, field(first, "cityField").getPrefWidth());
assertEquals(150, ((javafx.scene.layout.Region) header.getChildren().get(0)).getPrefWidth());
assertEquals(150, ((javafx.scene.layout.Region) header.getChildren().get(1)).getPrefWidth());
first.setModel(new Visit("Other", "Hamburg"));
assertEquals("Main", field(first, "streetField").getText());
});
}
/**
* A row keeps the height of its own fields.
*/
@Test
void aRowKeepsTheHeightOfItsOwnFields() throws Exception {
onFx(() -> {
Host host = host(new Visit("Tall", "Berlin"), new Visit("Main", "Bonn"));
LoadedView loaded = load("show-visits.fxml", host);
VBox body = body(table(loaded));
assertEquals(80, ((Region) body.getChildren().get(0)).getPrefHeight());
assertEquals(24, ((Region) body.getChildren().get(1)).getPrefHeight());
});
}
/**
* A row whose fields are swapped still loads.
*/
@Test
void aRowWhoseFieldsAreSwappedStillLoads() throws Exception {
onFx(() -> {
Visit main = new Visit("Main", "Berlin");
Host host = host(main);
LoadedView loaded = load("show-visits-swapped.fxml", host);
VBox body = body(table(loaded));
assertEquals(1, body.getChildren().size());
CityFirstRow row = (CityFirstRow) body.getChildren().get(0);
assertSame(main, row.getModel());
assertEquals("Berlin", field(row, "cityField").getText());
assertEquals("Main", field(row, "streetField").getText());
});
}
/**
* Adding and removing a visit updates the table and the entity.
*/
@Test
void addingAndRemovingAVisitUpdatesTheTableAndTheEntity() throws Exception {
onFx(() -> {
Visit main = new Visit("Main", "Berlin");
Visit oak = new Visit("Oak", "Bonn");
Host host = host(main, oak);
LoadedView loaded = load("show-visits.fxml", host);
@SuppressWarnings("unchecked")
ObservableList<Visit> visits = (ObservableList<Visit>) loaded.property("visits").getValue();
Visit elm = new Visit("Elm", "Hamburg");
visits.add(elm);
VBox body = body(table(loaded));
assertEquals(3, body.getChildren().size());
assertSame(elm, ((VisitRow) body.getChildren().get(2)).getModel());
assertEquals(List.of("Main", "Oak", "Elm"), streets(host.getVisits()));
visits.remove(oak);
assertEquals(2, body.getChildren().size());
assertSame(main, ((VisitRow) body.getChildren().get(0)).getModel());
assertSame(elm, ((VisitRow) body.getChildren().get(1)).getModel());
assertEquals(List.of("Main", "Elm"), streets(host.getVisits()));
});
}
/**
* A row scrolled out of sight still exists, and the header stays put.
*/
@Test
void aRowScrolledOutOfSightStillExists() throws Exception {
onFx(() -> {
List<Visit> visits = new ArrayList<>();
for (int index = 0; index < 12; index++) {
visits.add(new Visit("Street " + index, "Berlin"));
}
Host host = new Host();
host.setVisits(visits);
LoadedView loaded = load("show-visits.fxml", host);
Table table = table(loaded);
VBox body = body(table);
assertEquals(12, body.getChildren().size());
ScrollPane scroll = (ScrollPane) table.lookup("." + Table.SCROLL_STYLE);
assertSame(body, scroll.getContent());
HBox header = header(table);
assertFalse(scroll.getContent() instanceof Parent parent && parent.getChildrenUnmodifiable().contains(header));
assertSame(body, scroll.getContent());
});
}
/**
* An empty collection shows the headers and a blank body.
*/
@Test
void anEmptyCollectionShowsTheHeadersAndABlankBody() throws Exception {
onFx(() -> {
Host host = new Host();
LoadedView loaded = load("show-visits.fxml", host);
Table table = table(loaded);
assertEquals(2, header(table).getChildren().size());
assertEquals(0, body(table).getChildren().size());
assertNull(body(table).lookup(".label"));
});
}
/**
* A table with no column fails to load.
*/
@Test
void aTableWithNoColumnFailsToLoad() throws Exception {
onFx(() -> {
Host host = host(new Visit("Main", "Berlin"));
assertThrows(IllegalArgumentException.class, () -> load("show-visits-no-column.fxml", host));
});
}
/**
* A table that names one attribute twice fails to load.
*/
@Test
void aRepeatedAttributeFailsToLoad() throws Exception {
onFx(() -> {
Host host = host(new Visit("Main", "Berlin"));
assertThrows(IllegalArgumentException.class, () -> load("show-visits-duplicate.fxml", host));
});
}
/**
* A column that is not a simple value fails to load.
*/
@Test
void aComplexAttributeFailsToLoad() throws Exception {
onFx(() -> {
Host host = host(new Visit("Main", "Berlin"));
assertThrows(IllegalArgumentException.class, () -> load("show-visits-complex.fxml", host));
});
}
/**
* Loads a view of visits.
*
* @param name the document name
* @param host the model
* @return the loaded view
*/
private static LoadedView load(String name, Host host) {
URL fxml = ShowATableOfRowsTest.class.getResource(name);
assertNotNull(fxml);
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.component");
return ViewLoader.load(fxml, host, context);
}
/**
* Returns a host whose visits are {@code visits}.
*
* @param visits the visits
* @return the host
*/
private static Host host(Visit... visits) {
Host host = new Host();
host.setVisits(new ArrayList<>(List.of(visits)));
return host;
}
/**
* Returns the table in a loaded view.
*
* @param loaded the loaded view
* @return the table
*/
private static Table table(LoadedView loaded) {
return (Table) loaded.getRoot().lookup("#visitsTable");
}
/**
* Returns the header row.
*
* @param table the table
* @return the header
*/
private static HBox header(Table table) {
ScrollPane headerScroll = (ScrollPane) table.lookup(".table-header-scroll");
return (HBox) headerScroll.getContent();
}
/**
* Returns the header text of one Column.
*
* @param header the header row
* @param index the Column index
* @return the header text
*/
private static String headerText(HBox header, int index) {
Label label = (Label) header.getChildren().get(index).lookup("." + Table.HEADER_TEXT_STYLE);
return label.getText();
}
/**
* Returns the body that holds the rows.
*
* @param table the table
* @return the body
*/
private static VBox body(Table table) {
ScrollPane scroll = (ScrollPane) table.lookup("." + Table.SCROLL_STYLE);
assertInstanceOf(VBox.class, scroll.getContent());
return (VBox) scroll.getContent();
}
/**
* Returns a named field in a row.
*
* @param row the row
* @param id the field id
* @return the field
*/
private static TextField field(Parent row, String id) {
return (TextField) row.lookup("#" + id);
}
/**
* Returns the streets of the visits, in list order.
*
* @param visits the visits
* @return the streets
*/
private static List<String> streets(List<Visit> visits) {
return visits.stream().map(Visit::getStreet).toList();
}
/**
* Runs one check on the JavaFX application thread.
*
* @param action the check
*/
private static void onFx(FxAction action) throws Exception {
if (Platform.isFxApplicationThread()) {
action.run();
return;
}
AtomicReference<Throwable> 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);
}
}
/**
* One check that runs on the JavaFX application thread.
*/
@FunctionalInterface
private interface FxAction {
/**
* Runs the check.
*
* @throws Exception when the check fails
*/
void run() throws Exception;
}
}
@@ -0,0 +1,279 @@
package de.neitzel.fx.view;
import de.neitzel.fx.view.component.VisitRow;
import de.neitzel.fx.view.fixture.Host;
import de.neitzel.fx.view.fixture.Role;
import de.neitzel.fx.view.fixture.Visit;
import de.neitzel.injection.ApplicationContext;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.net.URL;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
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.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* A header click sorts one Column without rearranging the collection.
*/
class SortOneColumnTest {
/**
* Starts the JavaFX toolkit once for this class.
*/
@BeforeAll
static void startJavaFx() throws InterruptedException {
FxThread.start();
}
/**
* A header click switches direction and drops the other Column's Sort.
*/
@Test
void aHeaderClickSortsOneColumn() throws Exception {
FxThread.onFx(() -> {
Visit main = new Visit("Main", "Bonn");
Visit oak = new Visit("Oak", "Berlin");
Visit elm = new Visit("Elm", null);
Host host = host(main, oak, elm);
LoadedView loaded = load("show-two-tables.fxml", host);
Table first = (Table) loaded.getRoot().lookup("#firstTable");
clickHeader(first, 1);
assertTrue(mark(first, 1).isVisible());
assertEquals(List.of("Oak", "Main", "Elm"), streets(first));
assertEquals("ascending", mark(first, 1).getText());
assertFalse(mark(first, 0).isVisible());
clickHeader(first, 1);
assertEquals(List.of("Main", "Oak", "Elm"), streets(first));
assertEquals("descending", mark(first, 1).getText());
clickHeader(first, 0);
assertEquals(List.of("Elm", "Main", "Oak"), streets(first));
assertTrue(mark(first, 0).isVisible());
assertFalse(mark(first, 1).isVisible());
assertEquals(List.of("Main", "Oak", "Elm"), streets(host.getVisits()));
Table second = (Table) loaded.getRoot().lookup("#secondTable");
assertEquals(List.of("Main", "Oak", "Elm"), streets(second));
});
}
/**
* The popup sorts and clears, and a click outside leaves the Sort.
*/
@Test
void thePopupSortsAndClears() throws Exception {
FxThread.onFx(() -> {
Visit berlin = new Visit("Main", "berlin");
Visit capital = new Visit("Oak", "Berlin");
Visit aachen = new Visit("Elm", "Aachen");
Host host = host(berlin, capital, aachen);
Table table = (Table) load("show-two-tables.fxml", host).getRoot().lookup("#firstTable");
opener(table, 1).fire();
VBox popup = popup(table);
assertTrue(popup.isVisible());
command(popup, "Ascending").fire();
assertFalse(popup.isVisible());
assertEquals(List.of("Elm", "Main", "Oak"), streets(table));
opener(table, 1).fire();
table.fireEvent(click());
assertFalse(popup(table).isVisible());
assertEquals(List.of("Elm", "Main", "Oak"), streets(table));
opener(table, 1).fire();
command(popup(table), "Clear sort").fire();
assertEquals(List.of("Main", "Oak", "Elm"), streets(table));
assertFalse(mark(table, 1).isVisible());
});
}
/**
* Numbers, booleans, enums, and dates sort in their own order. Missing values stay last.
*/
@Test
void eachTypeHasItsOwnOrder() throws Exception {
FxThread.onFx(() -> {
Visit low = visit("Low", 1, false, Role.ADMIN, LocalDate.of(2020, 1, 2));
Visit high = visit("High", 10, true, Role.MEMBER, LocalDate.of(2019, 5, 1));
Visit missing = visit("Missing", null, false, null, null);
Host host = host(high, missing, low);
Table table = (Table) load("show-two-tables.fxml", host).getRoot().lookup("#firstTable");
clickHeader(table, 2);
assertEquals(List.of("Low", "High", "Missing"), streets(table));
clickHeader(table, 3);
assertEquals(List.of("Missing", "Low", "High"), streets(table));
clickHeader(table, 4);
assertEquals(List.of("High", "Low", "Missing"), streets(table));
clickHeader(table, 5);
assertEquals(List.of("High", "Low", "Missing"), streets(table));
});
}
/**
* Returns a visit with the extra Attributes filled in.
*
* @param street the street
* @param rank the rank
* @param open whether the visit is open
* @param role the role
* @param day the day
* @return the visit
*/
private static Visit visit(String street, Integer rank, boolean open, Role role, LocalDate day) {
Visit visit = new Visit(street, "Berlin");
visit.setRank(rank);
visit.setOpen(open);
visit.setRole(role);
visit.setDay(day);
return visit;
}
/**
* Loads a document.
*
* @param name the document name
* @param host the model
* @return the loaded view
*/
private static LoadedView load(String name, Host host) {
URL fxml = SortOneColumnTest.class.getResource(name);
assertNotNull(fxml);
return ViewLoader.load(fxml, host, new ApplicationContext("de.neitzel.fx.view.component"));
}
/**
* Returns a host with those visits.
*
* @param visits the visits
* @return the host
*/
private static Host host(Visit... visits) {
Host host = new Host();
host.setVisits(new ArrayList<>(List.of(visits)));
return host;
}
/**
* Clicks a Column header.
*
* @param table the table
* @param index the Column index
*/
private static void clickHeader(Table table, int index) {
HBox header = header(table);
header.getChildren().get(index).fireEvent(click());
}
/**
* Returns the header row.
*
* @param table the table
* @return the header
*/
private static HBox header(Table table) {
ScrollPane headerScroll = (ScrollPane) table.lookup(".table-header-scroll");
return (HBox) headerScroll.getContent();
}
/**
* Returns the sort mark of one Column.
*
* @param table the table
* @param index the Column index
* @return the mark
*/
private static Label mark(Table table, int index) {
return (Label) header(table).getChildren().get(index).lookup("." + Table.SORT_MARK_STYLE);
}
/**
* Returns the opener of one Column.
*
* @param table the table
* @param index the Column index
* @return the opener
*/
private static Button opener(Table table, int index) {
return (Button) header(table).getChildren().get(index).lookup("." + Table.OPENER_STYLE);
}
/**
* Returns the popup.
*
* @param table the table
* @return the popup
*/
private static VBox popup(Table table) {
return (VBox) table.lookup("." + Table.POPUP_STYLE);
}
/**
* Returns a popup command.
*
* @param popup the popup
* @param text the command text
* @return the button
*/
private static Button command(VBox popup, String text) {
return popup.getChildren().stream()
.filter(Button.class::isInstance)
.map(Button.class::cast)
.filter(button -> text.equals(button.getText()))
.findFirst()
.orElseThrow();
}
/**
* Returns the streets of the visible rows.
*
* @param table the table
* @return the streets
*/
private static List<String> streets(Table table) {
return body(table).getChildren().stream()
.map(node -> ((javafx.scene.control.TextField) ((javafx.scene.Parent) node).lookup("#streetField"))
.getText())
.toList();
}
/**
* Returns the streets of the collection.
*
* @param visits the visits
* @return the streets
*/
private static List<String> streets(List<Visit> visits) {
return visits.stream().map(Visit::getStreet).toList();
}
/**
* Returns the body.
*
* @param table the table
* @return the body
*/
private static VBox body(Table table) {
ScrollPane scroll = (ScrollPane) table.lookup("." + Table.SCROLL_STYLE);
return (VBox) scroll.getContent();
}
/**
* Returns a primary click.
*
* @return the event
*/
private static MouseEvent click() {
return new MouseEvent(MouseEvent.MOUSE_CLICKED, 1, 1, 1, 1, MouseButton.PRIMARY, 1,
false, false, false, false, true, false, false, false, false, false, null);
}
}
@@ -0,0 +1,44 @@
package de.neitzel.fx.view.component;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.layout.HBox;
/**
* A Row that lays city out before street.
*/
@Singleton
public class CityFirstRow extends HBox {
/**
* The visit this row shows.
*/
private final ObjectProperty<Object> model = new SimpleObjectProperty<>(this, "model");
/**
* Creates a row.
*/
@Inject
public CityFirstRow() {
}
/**
* Returns the model property.
*
* @return the model property
*/
public ObjectProperty<Object> modelProperty() {
return model;
}
/**
* Returns the visit this row shows.
*
* @return the visit, or {@code null}
*/
public Object getModel() {
return model.get();
}
}
@@ -0,0 +1,76 @@
package de.neitzel.fx.view.component;
import de.neitzel.fx.view.fixture.Visit;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.fxml.FXML;
import javafx.scene.control.TextField;
import javafx.scene.layout.HBox;
/**
* A Row that shows a visit's street and city, street first.
*/
@Singleton
public class VisitRow extends HBox {
/**
* The visit this row shows.
*/
private final ObjectProperty<Object> model = new SimpleObjectProperty<>(this, "model");
/**
* The street field, bound by this row.
*/
@FXML
private TextField streetField;
/**
* Creates a row.
*/
@Inject
public VisitRow() {
model.addListener((observable, previous, current) -> applyHeight(current));
}
/**
* Gives a visit named Tall a taller row. Every other visit stays one line.
*
* @param current the model, or {@code null}
*/
private void applyHeight(Object current) {
if (current instanceof Visit visit && "Tall".equals(visit.getStreet())) {
setPrefHeight(80);
} else {
setPrefHeight(24);
}
}
/**
* Returns the model property.
*
* @return the model property
*/
public ObjectProperty<Object> modelProperty() {
return model;
}
/**
* Returns the visit this row shows.
*
* @return the visit, or {@code null}
*/
public Object getModel() {
return model.get();
}
/**
* Sets the visit this row shows.
*
* @param model the visit
*/
public void setModel(Object model) {
this.model.set(model);
}
}
@@ -0,0 +1,56 @@
package de.neitzel.fx.view.fixture;
import java.util.ArrayList;
import java.util.List;
/**
* A model whose visits a Table shows.
*/
public class Host {
/**
* The visits. After a view loads, this is the list the Table watches.
*/
private List<Visit> visits = new ArrayList<>();
/**
* The selected visit, or {@code null} when none is selected.
*/
private Visit selectedVisit;
/**
* Returns the visits.
*
* @return the visits
*/
public List<Visit> getVisits() {
return visits;
}
/**
* Sets the visits.
*
* @param visits the visits
*/
public void setVisits(List<Visit> visits) {
this.visits = visits;
}
/**
* Returns the selected visit.
*
* @return the selected visit, or {@code null}
*/
public Visit getSelectedVisit() {
return selectedVisit;
}
/**
* Sets the selected visit.
*
* @param selectedVisit the selected visit
*/
public void setSelectedVisit(Visit selectedVisit) {
this.selectedVisit = selectedVisit;
}
}
@@ -0,0 +1,179 @@
package de.neitzel.fx.view.fixture;
/**
* One visit a table can show.
*/
public class Visit {
/**
* The street.
*/
private String street;
/**
* The city.
*/
private String city;
/**
* A whole number, used when a Column sorts or filters by magnitude.
*/
private Integer rank;
/**
* Whether the visit is open.
*/
private boolean open;
/**
* The visitor's role.
*/
private Role role;
/**
* The day of the visit.
*/
private java.time.LocalDate day;
/**
* A nested address. A Column cannot use this Attribute.
*/
private Address place;
/**
* Creates a visit.
*
* @param street the street
* @param city the city
*/
public Visit(String street, String city) {
this.street = street;
this.city = city;
}
/**
* Returns the street.
*
* @return the street
*/
public String getStreet() {
return street;
}
/**
* Sets the street.
*
* @param street the street
*/
public void setStreet(String street) {
this.street = street;
}
/**
* Returns the city.
*
* @return the city
*/
public String getCity() {
return city;
}
/**
* Sets the city.
*
* @param city the city
*/
public void setCity(String city) {
this.city = city;
}
/**
* Returns the rank.
*
* @return the rank, or {@code null}
*/
public Integer getRank() {
return rank;
}
/**
* Sets the rank.
*
* @param rank the rank
*/
public void setRank(Integer rank) {
this.rank = rank;
}
/**
* Returns whether the visit is open.
*
* @return {@code true} when the visit is open
*/
public boolean isOpen() {
return open;
}
/**
* Sets whether the visit is open.
*
* @param open whether the visit is open
*/
public void setOpen(boolean open) {
this.open = open;
}
/**
* Returns the role.
*
* @return the role, or {@code null}
*/
public Role getRole() {
return role;
}
/**
* Sets the role.
*
* @param role the role
*/
public void setRole(Role role) {
this.role = role;
}
/**
* Returns the day.
*
* @return the day, or {@code null}
*/
public java.time.LocalDate getDay() {
return day;
}
/**
* Sets the day.
*
* @param day the day
*/
public void setDay(java.time.LocalDate day) {
this.day = day;
}
/**
* Returns the nested address.
*
* @return the nested address, or {@code null}
*/
public Address getPlace() {
return place;
}
/**
* Sets the nested address.
*
* @param place the nested address
*/
public void setPlace(Address place) {
this.place = place;
}
}
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.HBox?>
<?import de.neitzel.fx.view.BindingList?>
<?import de.neitzel.fx.view.DirectBinding?>
<fx:root type="javafx.scene.layout.HBox" xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml">
<fx:define>
<BindingList>
<DirectBinding control="cityField" property="text" path="city" direction="both"/>
<DirectBinding control="streetField" property="text" path="street" direction="both"/>
</BindingList>
</fx:define>
<TextField fx:id="cityField" id="cityField"/>
<TextField fx:id="streetField" id="streetField"/>
</fx:root>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.HBox?>
<?import de.neitzel.fx.view.BindingList?>
<?import de.neitzel.fx.view.DirectBinding?>
<fx:root type="javafx.scene.layout.HBox" xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml">
<fx:define>
<BindingList>
<DirectBinding control="streetField" property="text" path="street" direction="both"/>
<DirectBinding control="cityField" property="text" path="city" direction="both"/>
</BindingList>
</fx:define>
<TextField fx:id="streetField" id="streetField"/>
<TextField fx:id="cityField" id="cityField"/>
</fx:root>
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.layout.VBox?>
<?import de.neitzel.fx.view.Column?>
<?import de.neitzel.fx.view.Table?>
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
<Table fx:id="firstTable" id="firstTable" collection="visits"
row="de.neitzel.fx.view.component.VisitRow" prefWidth="300">
<Column header="Street" attribute="street"/>
<Column header="City" attribute="city"/>
<Column header="Rank" attribute="rank"/>
<Column header="Open" attribute="open"/>
<Column header="Role" attribute="role"/>
<Column header="Day" attribute="day"/>
</Table>
<Table fx:id="secondTable" id="secondTable" collection="visits"
row="de.neitzel.fx.view.component.VisitRow" prefWidth="300">
<Column header="City" attribute="city"/>
</Table>
</VBox>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.layout.VBox?>
<?import de.neitzel.fx.view.Column?>
<?import de.neitzel.fx.view.Table?>
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
<Table fx:id="visitsTable" id="visitsTable" collection="visits"
row="de.neitzel.fx.view.component.VisitRow">
<Column header="Place" attribute="place"/>
</Table>
</VBox>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.layout.VBox?>
<?import de.neitzel.fx.view.Column?>
<?import de.neitzel.fx.view.Table?>
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
<Table fx:id="visitsTable" id="visitsTable" collection="visits"
row="de.neitzel.fx.view.component.VisitRow">
<Column header="Street" attribute="street"/>
<Column header="Street again" attribute="street"/>
</Table>
</VBox>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.layout.VBox?>
<?import de.neitzel.fx.view.Table?>
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
<Table fx:id="visitsTable" id="visitsTable" collection="visits"
row="de.neitzel.fx.view.component.VisitRow"/>
</VBox>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.layout.VBox?>
<?import de.neitzel.fx.view.BindingList?>
<?import de.neitzel.fx.view.Column?>
<?import de.neitzel.fx.view.DirectBinding?>
<?import de.neitzel.fx.view.Table?>
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
<fx:define>
<BindingList>
<DirectBinding control="visitsTable" property="selection" path="selectedVisit" direction="both"/>
</BindingList>
</fx:define>
<Table fx:id="visitsTable" id="visitsTable" collection="visits"
row="de.neitzel.fx.view.component.VisitRow" prefWidth="300" prefHeight="200">
<Column header="Street" attribute="street"/>
<Column header="City" attribute="city"/>
</Table>
</VBox>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.layout.VBox?>
<?import de.neitzel.fx.view.Column?>
<?import de.neitzel.fx.view.Table?>
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
<Table fx:id="visitsTable" id="visitsTable" collection="visits"
row="de.neitzel.fx.view.component.CityFirstRow" prefWidth="300">
<Column header="Street" attribute="street"/>
<Column header="City" attribute="city"/>
</Table>
</VBox>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.layout.VBox?>
<?import de.neitzel.fx.view.Column?>
<?import de.neitzel.fx.view.Table?>
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
<Table fx:id="visitsTable" id="visitsTable" collection="visits"
row="de.neitzel.fx.view.component.VisitRow" prefWidth="300">
<Column header="Street" attribute="street"/>
<Column header="City" attribute="city"/>
</Table>
</VBox>