/*******************************************************************************
* Copyright (c) 2015, 2016 itemis AG and others.
*
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.0
* which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v10.html
*
* Contributors:
* Matthias Wienand (itemis AG) - initial API and implementation
*
*******************************************************************************/
package org.eclipse.gef.mvc.tests.fx.rules;
import java.awt.AWTException;
import java.awt.BorderLayout;
import java.awt.Robot;
import java.awt.event.InputEvent;
import java.lang.Thread.UncaughtExceptionHandler;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import javax.swing.JFrame;
import javax.swing.SwingUtilities;
import org.junit.rules.TestRule;
import org.junit.runner.Description;
import org.junit.runners.model.Statement;
import javafx.application.Platform;
import javafx.embed.swing.JFXPanel;
import javafx.event.Event;
import javafx.event.EventHandler;
import javafx.event.EventType;
import javafx.geometry.Point2D;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.input.PickResult;
/**
* A {@link TestRule} to ensure that the JavaFX toolkit is properly initialized
* before test execution. This rule does also serve as a context/utility for
* JavaFX tests:
* <ul>
* <li>{@link #createScene(Parent, double, double)} creates a {@link Scene} and
* shows it inside a {@link JFrame}.
* <li>{@link #runAndWait(Runnable)} executes the given {@link Runnable} on the
* JavaFX application thread and waits until the {@link Runnable} has been
* executed.
* <li>{@link #getEventSynchronizer(EventType)} registers an
* {@link EventSynchronizer} for the given {@link EventType}. A corresponding
* event can be fired after the synchronizer is registered. You can then wait
* for the event processing by calling {@link EventSynchronizer#await()}.
* <li>{@link #moveTo(Robot, Node, double, double)} moves the mouse to the
* specified position within the given {@link Node}. An exception is thrown if
* the mouse does not enter the node.
* </ul>
* Additionally, some convenience methods are provided to fire and wait for
* specific events: {@link #mousePress(Robot, int)},
* {@link #mouseDrag(Robot, int, int)}, {@link #mouseRelease(Robot, int)},
* {@link #keyPress(Robot, int)}, and {@link #keyRelease(Robot, int)}.
*
* @author mwienand
*
*/
public class FXNonApplicationThreadRule implements TestRule {
/**
* Saves data for the last keyboard interaction so that it can be reused for
* subsequent keyboard interaction.
*/
public static class KeyInteraction {
public Node target;
public KeyCode keyCode;
}
/**
* An instance of {@link Modifiers} represents a set of modifier keys
* (shift, control, alt, meta). For convenience, {@link Modifiers#NONE} can
* be used to specify that no modifier keys are pressed, and
* {@link Modifiers#ALL} can be used to specify that all modifier keys are
* pressed.
* <p>
* Starting with a set of modifier keys, you can use the
* {@link Modifiers#alt(boolean)}, {@link Modifiers#control(boolean)},
* {@link Modifiers#meta(boolean)}, and {@link Modifiers#shift(boolean)}
* methods to create new sets of modifier keys with the changed attribute.
* Moreover, the {@link Modifiers#talt()}, {@link Modifiers#tcontrol()},
* {@link Modifiers#tmeta()}, {@link Modifiers#tshift()} methods can be used
* to create a set of modifier keys with the respective value toggled, i.e.
* <quote>
*
* <pre>
* // press "A" while control, meta, and shift modifier keys are pressed
* keyPress(targetNode, KeyCode.A, Modifiers.ALL.talt());
* // press "B" while meta and shift modifier keys are pressed
* keyPress(targetNode, KeyCode.B, Modifiers.NONE.tmeta().tshift());
* // move mouse while alt and meta modifier keys are pressed
* mouseMove(targetNode, sceneX, sceneY, Modifiers.ALL.tcontrol().tshift());
* </pre>
*
* </quote>
*/
public static class Modifiers {
public static final Modifiers NONE = new Modifiers();
public static final Modifiers ALL = new Modifiers(true, true, true, true);
public boolean shift;
public boolean control;
public boolean alt;
public boolean meta;
public Modifiers() {
}
public Modifiers(boolean alt, boolean control, boolean meta, boolean shift) {
this.shift = shift;
this.control = control;
this.alt = alt;
this.meta = meta;
}
public Modifiers(Modifiers o) {
this.shift = o.shift;
this.control = o.control;
this.alt = o.alt;
this.meta = o.meta;
}
public Modifiers alt(boolean alt) {
Modifiers copy = getCopy();
copy.alt = alt;
return copy;
}
public Modifiers control(boolean control) {
Modifiers copy = getCopy();
copy.control = control;
return copy;
}
public Modifiers getCopy() {
return new Modifiers(this);
}
public Modifiers meta(boolean meta) {
Modifiers copy = getCopy();
copy.meta = meta;
return copy;
}
public Modifiers shift(boolean shift) {
Modifiers copy = getCopy();
copy.shift = shift;
return copy;
}
public Modifiers talt() {
return alt(!alt);
}
public Modifiers tcontrol() {
return control(!control);
}
public Modifiers tmeta() {
return meta(!meta);
}
public Modifiers tshift() {
return shift(!shift);
}
}
/**
* Saves mouse interaction data, so that it can be reused for subsequent
* mouse interaction.
*/
public static class MouseInteraction {
public Node target;
public double sceneX;
public double sceneY;
}
public interface RunnableWithResult<T> {
public T run();
}
public interface RunnableWithResultAndParam<T, P1> {
public T run(P1 param1);
}
/**
* A {@link SyncEvent} can be used to ensure that all events have been
* processed.
* <ol>
* <li>Register an event filter for {@link SyncEvent#SYNC} that notifies
* when it is executed.
* <li>Fire a {@link SyncEvent#SYNC}.
* <li>Wait for the notification from the event filter.
* </ol>
* This mechanism is implemented by {@link FxSynthRobot#waitForIdle()}.
*/
public static class SyncEvent extends Event {
private static final long serialVersionUID = 1L;
/**
* A {@link SyncEvent#SYNC} event can be used to ensure that all events
* have been processed.
*/
public static final EventType<SyncEvent> SYNC = new EventType<>(EventType.ROOT);
/**
* Creates a new {@link SyncEvent#SYNC}.
*/
public SyncEvent() {
super(SYNC);
}
/**
* Creates a new {@link SyncEvent} of the given {@link EventType}.
*
* @param eventType
* The {@link EventType} of the newly created
* {@link SyncEvent}.
*/
public SyncEvent(EventType<? extends SyncEvent> eventType) {
super(eventType);
}
}
private static final long TIMEOUT_MILLIS = 5000;
private static boolean initializedJavaFxToolkit = false;
private synchronized static void initFX() throws InterruptedException {
if (!initializedJavaFxToolkit) {
final CountDownLatch latch = new CountDownLatch(1);
SwingUtilities.invokeLater(new Runnable() {
@Override
public void run() {
new JFXPanel(); // initializes JavaFX
latch.countDown();
}
});
latch.await();
initializedJavaFxToolkit = true;
}
}
private MouseInteraction lastMouseInteraction;
private KeyInteraction lastKeyInteraction;
private Scene scene;
private JFXPanel panel;
private JFrame jFrame;
@Override
public Statement apply(final Statement base, Description description) {
if (Platform.isFxApplicationThread() || SwingUtilities.isEventDispatchThread()) {
throw new IllegalStateException(
"Tests may not be executed from FX application or AWT event dispatching thread.");
}
return new Statement() {
@Override
public void evaluate() throws Throwable {
initFX();
try {
base.evaluate();
} finally {
runAndWait(() -> {
if (panel != null) {
panel.setScene(null);
}
if (jFrame != null) {
jFrame.setVisible(false);
jFrame = null;
}
scene = null;
panel = null;
});
}
}
};
}
/**
* Creates a {@link Scene} that wraps the given root visual and shows that
* {@link Scene} in a {@link JFrame}.
*
* @param root
* The root visual.
* @param width
* The width of the frame/scene.
* @param height
* The height of the frame/scene.
* @return The created {@link Scene}.
* @throws Throwable
* @throws InterruptedException
* @throws AWTException
*/
public Scene createScene(final Parent root, final double width, final double height) throws Throwable {
scene = runAndWait(new RunnableWithResult<Scene>() {
@Override
public Scene run() {
// hook viewer to scene
panel = new JFXPanel();
Scene scene = new Scene(root, width, height);
panel.setScene(scene);
jFrame = new JFrame();
jFrame.setBounds(0, 0, (int) width, (int) height);
jFrame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
jFrame.setLayout(new BorderLayout());
jFrame.setLocationRelativeTo(null);
jFrame.setVisible(true);
jFrame.setContentPane(panel);
Thread.currentThread().setUncaughtExceptionHandler(new UncaughtExceptionHandler() {
@Override
public void uncaughtException(Thread t, Throwable e) {
if (e instanceof RuntimeException) {
throw ((RuntimeException) e);
}
throw new RuntimeException(e);
}
});
return scene;
}
});
return scene;
}
public JFXPanel getPanel() {
return panel;
}
/**
* Fires a KEY_PRESSED event and waits for its processing to finish. See
* {@link Robot#keyPress(int)} for more information.
*
* @param robot
* @param keycode
* @throws InterruptedException
*/
public synchronized void keyPress(final Node target, final KeyCode keycode) throws Throwable {
keyPress(target, keycode, Modifiers.NONE);
}
/**
* Fires a newly created {@link KeyEvent} of type
* {@link KeyEvent#KEY_PRESSED} to the given target {@link Node}. The given
* {@link KeyCode} and {@link Modifiers} are used to construct the
* {@link KeyEvent}.
* <p>
* The target {@link Node} and the {@link KeyCode} is saved for subsequent
* {@link #keyRelease(Modifiers)} calls.
*
* @param target
* The target {@link Node} that the event is send to.
* @param key
* The {@link KeyCode} for the {@link KeyEvent}.
* @param mods
* The {@link Modifiers} for the {@link KeyEvent}.
*/
public void keyPress(final Node target, final KeyCode key, final Modifiers mods) {
waitForIdle();
// save key interaction data
lastKeyInteraction = new KeyInteraction();
lastKeyInteraction.target = target;
lastKeyInteraction.keyCode = key;
run(() -> {
Event.fireEvent(target, new KeyEvent(target, target, KeyEvent.KEY_PRESSED, key.toString(), key.toString(),
key, mods.shift, mods.control, mods.alt, mods.meta));
});
waitForIdle();
}
/**
* Fires a KEY_RELEASED event and waits for its processing to finish.
*
* @param robot
* @param keycode
* @throws InterruptedException
*/
public synchronized void keyRelease() throws Throwable {
keyRelease(Modifiers.NONE);
}
/**
* Fires a newly created {@link KeyEvent} of type
* {@link KeyEvent#KEY_RELEASED} to the target {@link Node} of the last
* {@link #keyPress(Node, KeyCode, Modifiers)}. The {@link KeyCode} of the
* last {@link #keyPress(Node, KeyCode, Modifiers)} is reused, however, the
* given {@link Modifiers} are used for the new {@link KeyEvent}.
*
* @param mods
* The {@link Modifiers} for the {@link KeyEvent}.
*/
public void keyRelease(final Modifiers mods) {
waitForIdle();
run(() -> {
Event.fireEvent(lastKeyInteraction.target,
new KeyEvent(lastKeyInteraction.target, lastKeyInteraction.target, KeyEvent.KEY_RELEASED,
lastKeyInteraction.keyCode.toString(), lastKeyInteraction.keyCode.toString(),
lastKeyInteraction.keyCode, mods.shift, mods.control, mods.alt, mods.meta));
});
waitForIdle();
lastKeyInteraction = null;
}
/**
* Fires a MOUSE_DRAGGED event and waits for its processing to finish.
*
* @param robot
* @param keycode
* @throws InterruptedException
*/
public synchronized void mouseDrag(final double sceneX, final double sceneY) throws Throwable {
mouseDrag(sceneX, sceneY, Modifiers.NONE);
}
/**
* Fires a newly created {@link MouseEvent} of type
* {@link MouseEvent#MOUSE_DRAGGED} to the target {@link Node} of the last
* mouse interaction.
*
* @param sceneX
* The final x-coordinate (in scene) for the drag.
* @param sceneY
* The final y-coordinate (in scene) for the drag.
* @param mods
* The {@link Modifiers} for the {@link MouseEvent}.
*/
public void mouseDrag(final double sceneX, final double sceneY, final Modifiers mods) {
waitForIdle();
// save mouse interaction data
lastMouseInteraction.sceneX = sceneX;
lastMouseInteraction.sceneY = sceneY;
run(() -> {
Point2D local = lastMouseInteraction.target.sceneToLocal(sceneX, sceneY);
Point2D screen = lastMouseInteraction.target.localToScreen(local.getX(), local.getY());
Event.fireEvent(lastMouseInteraction.target,
new MouseEvent(lastMouseInteraction.target, lastMouseInteraction.target, MouseEvent.MOUSE_DRAGGED,
local.getX(), local.getY(), screen.getX(), screen.getY(), MouseButton.PRIMARY, 0,
mods.shift, mods.control, mods.alt, mods.meta, true, false, false, false, false, false,
new PickResult(lastMouseInteraction.target, sceneX, sceneY)));
});
waitForIdle();
}
public synchronized void mouseMove(final Node target, final double sceneX, final double sceneY) throws Throwable {
mouseMove(target, sceneX, sceneY, Modifiers.NONE);
waitForIdle();
}
/**
* Fires a newly created {@link MouseEvent} of type
* {@link MouseEvent#MOUSE_MOVED} to the given target {@link Node}.
*
* @param sceneX
* The final x-coordinate (in scene) for the drag.
* @param sceneY
* The final y-coordinate (in scene) for the drag.
* @param mods
* The {@link Modifiers} for the {@link MouseEvent}.
*/
public void mouseMove(final Node target, final double sceneX, final double sceneY, final Modifiers mods) {
waitForIdle();
// save mouse interaction data
lastMouseInteraction = new MouseInteraction();
lastMouseInteraction.target = target;
lastMouseInteraction.sceneX = sceneX;
lastMouseInteraction.sceneY = sceneY;
run(() -> {
Point2D local = target.sceneToLocal(sceneX, sceneY);
Point2D screen = target.localToScreen(local.getX(), local.getY());
Event.fireEvent(target,
new MouseEvent(target, target, MouseEvent.MOUSE_MOVED, local.getX(), local.getY(), screen.getX(),
screen.getY(), MouseButton.NONE, 0, mods.shift, mods.control, mods.alt, mods.meta, false,
false, false, false, false, false, new PickResult(target, sceneX, sceneY)));
});
waitForIdle();
}
/**
* Fires a MOUSE_PRESSED event and waits for its processing to finish. The
* given buttons mask can be composed by adding the following constants:
* <ul>
* <li>{@link InputEvent#BUTTON1_DOWN_MASK}
* <li>{@link InputEvent#BUTTON2_DOWN_MASK}
* <li>{@link InputEvent#BUTTON3_DOWN_MASK}
* </ul>
* See {@link Robot#mousePress(int)} for more information.
*
* @param robot
* @param keycode
* @throws InterruptedException
*/
public synchronized void mousePress() throws Throwable {
mousePress(Modifiers.NONE);
}
/**
* Fires a newly created {@link MouseEvent} of type
* {@link MouseEvent#MOUSE_PRESSED} to the target {@link Node} of the last
* mouse interaction.
*
* @param mods
* The {@link Modifiers} for the {@link MouseEvent}.
*/
public void mousePress(final Modifiers mods) {
waitForIdle();
run(() -> {
Point2D local = lastMouseInteraction.target.sceneToLocal(lastMouseInteraction.sceneX,
lastMouseInteraction.sceneY);
Point2D screen = lastMouseInteraction.target.localToScreen(local.getX(), local.getY());
Event.fireEvent(lastMouseInteraction.target,
new MouseEvent(lastMouseInteraction.target, lastMouseInteraction.target, MouseEvent.MOUSE_PRESSED,
local.getX(), local.getY(), screen.getX(), screen.getY(), MouseButton.PRIMARY, 1,
mods.shift, mods.control, mods.alt, mods.meta, true, false, false, false, false, false,
new PickResult(lastMouseInteraction.target, lastMouseInteraction.sceneX,
lastMouseInteraction.sceneY)));
});
waitForIdle();
}
/**
* Fires a MOUSE_RELEASED event and waits for its processing to finish.
*
* @param robot
* @param keycode
* @throws InterruptedException
*/
public synchronized void mouseRelease() throws Throwable {
mouseRelease(Modifiers.NONE);
}
/**
* Fires a newly created {@link MouseEvent} of type
* {@link MouseEvent#MOUSE_RELEASED} to the target {@link Node} of the last
* mouse interaction.
*
* @param mods
* The {@link Modifiers} for the {@link MouseEvent}.
*/
public void mouseRelease(final Modifiers mods) {
waitForIdle();
run(() -> {
Point2D local = lastMouseInteraction.target.sceneToLocal(lastMouseInteraction.sceneX,
lastMouseInteraction.sceneY);
Point2D screen = lastMouseInteraction.target.localToScreen(local.getX(), local.getY());
Event.fireEvent(lastMouseInteraction.target,
new MouseEvent(lastMouseInteraction.target, lastMouseInteraction.target, MouseEvent.MOUSE_RELEASED,
local.getX(), local.getY(), screen.getX(), screen.getY(), MouseButton.PRIMARY, 1,
mods.shift, mods.control, mods.alt, mods.meta, false, false, false, false, false, false,
new PickResult(lastMouseInteraction.target, lastMouseInteraction.sceneX,
lastMouseInteraction.sceneY)));
});
waitForIdle();
}
/**
* Executes the given {@link Runnable} on the JavaFX application thread. If
* the current thread is the JavaFX application thread, then the given
* {@link Runnable} is executed in-line. Otherwise, the given
* {@link Runnable} is passed to a {@link Platform#runLater(Runnable)}.
*
* @param r
* The {@link Runnable} to execute on the JavaFX application
* thread.
*/
protected void run(Runnable r) {
if (Platform.isFxApplicationThread()) {
// run right now
r.run();
} else {
// run later
Platform.runLater(r);
}
}
/**
* Schedules the given {@link Runnable} on the JavaFX application thread and
* waits for its execution to finish.
*
* @param runnable
* @throws Throwable
*/
public synchronized void runAndWait(final Runnable runnable) throws Throwable {
final AtomicReference<Throwable> throwableRef = new AtomicReference<>(null);
final CountDownLatch latch = new CountDownLatch(1);
run(() -> {
try {
runnable.run();
} catch (Throwable t) {
throwableRef.set(t);
} finally {
latch.countDown();
}
});
wait(latch);
Throwable throwable = throwableRef.get();
if (throwable != null) {
throw throwable;
}
}
/**
* Schedules the given {@link RunnableWithResult} on the JavaFX application
* thread and waits for its execution to finish.
*
* @param runnableWithResult
* @throws Throwable
*/
public synchronized <T> T runAndWait(final RunnableWithResult<T> runnableWithResult) throws Throwable {
final AtomicReference<Throwable> throwableRef = new AtomicReference<>(null);
final AtomicReference<T> resultRef = new AtomicReference<>(null);
final CountDownLatch latch = new CountDownLatch(1);
run(() -> {
try {
resultRef.set(runnableWithResult.run());
} catch (Throwable t) {
throwableRef.set(t);
} finally {
latch.countDown();
}
});
wait(latch);
Throwable throwable = throwableRef.get();
if (throwable != null) {
throw throwable;
}
return resultRef.get();
}
/**
* Schedules the given {@link RunnableWithResult} on the JavaFX application
* thread and waits for its execution to finish.
*
* @param runnableWithResult
* @throws Throwable
*/
public synchronized <T, P1> T runAndWait(final RunnableWithResultAndParam<T, P1> runnableWithResult,
final P1 param1) throws Throwable {
final AtomicReference<Throwable> throwableRef = new AtomicReference<>(null);
final AtomicReference<T> resultRef = new AtomicReference<>(null);
final CountDownLatch latch = new CountDownLatch(1);
run(() -> {
try {
resultRef.set(runnableWithResult.run(param1));
} catch (Throwable t) {
throwableRef.set(t);
} finally {
latch.countDown();
}
});
wait(latch);
Throwable throwable = throwableRef.get();
if (throwable != null) {
throw throwable;
}
return resultRef.get();
}
/**
* If the current thread is the JavaFX application thread, busy waiting is
* performed, i.e. {@link Thread#sleep(long)} is called in-between checking
* of a {@link CountDownLatch}. Otherwise,
* {@link CountDownLatch#await(long, TimeUnit)} is used to wait. In either
* case, an exception is thrown if the timeout of {@link #TIMEOUT_MILLIS} is
* exceeded.
*
* @param latch
* The {@link CountDownLatch} that is waited for.
*/
protected void wait(CountDownLatch latch) {
try {
if (Platform.isFxApplicationThread()) {
// busy waiting
long startMillis = System.currentTimeMillis();
while (latch.getCount() > 0) {
Thread.sleep(100);
if ((System.currentTimeMillis() - startMillis) > TIMEOUT_MILLIS) {
throw new IllegalStateException("TIMEOUT");
}
}
} else {
// sleepy waiting
latch.await(TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
/**
* Ensures that processing of previously fired events is finished by firing
* and waiting for a {@link SyncEvent}.
* <p>
* This method should only be called after firing an {@link Event} that is
* not supported by this {@link FXNonApplicationThreadRule}, e.g. by using
* {@link Event#fireEvent(javafx.event.EventTarget, Event)}.
*/
public void waitForIdle() {
final CountDownLatch latch = new CountDownLatch(1);
EventHandler<? super SyncEvent> eventFilter = event -> {
latch.countDown();
};
run(() -> {
scene.addEventFilter(SyncEvent.SYNC, eventFilter);
});
run(() -> {
Event.fireEvent(scene, new SyncEvent());
});
wait(latch);
run(() -> {
scene.removeEventFilter(SyncEvent.SYNC, eventFilter);
});
}
}