This is an automated email from the ASF dual-hosted git repository.
hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git
The following commit(s) were added to refs/heads/main by this push:
new 5633017df1 Hop creation hardening and unit tests, fixes #7768 (#7808)
5633017df1 is described below
commit 5633017df133baaea50c1d1c774c7e9087f9560a
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Thu Aug 6 20:38:50 2026 +0200
Hop creation hardening and unit tests, fixes #7768 (#7808)
---
.../hop/ui/hopgui/file/GraphCanvasTestBase.java | 351 +++++++++++
.../HopGuiPipelineGraphHopCreationTest.java | 628 ++++++++++++++++++++
.../HopGuiWorkflowGraphHopCreationTest.java | 642 +++++++++++++++++++++
.../hopgui/file/pipeline/HopGuiPipelineGraph.java | 38 +-
.../hopgui/file/workflow/HopGuiWorkflowGraph.java | 44 +-
5 files changed, 1691 insertions(+), 12 deletions(-)
diff --git
a/rcp/src/test/java/org/apache/hop/ui/hopgui/file/GraphCanvasTestBase.java
b/rcp/src/test/java/org/apache/hop/ui/hopgui/file/GraphCanvasTestBase.java
new file mode 100644
index 0000000000..de2c783b4d
--- /dev/null
+++ b/rcp/src/test/java/org/apache/hop/ui/hopgui/file/GraphCanvasTestBase.java
@@ -0,0 +1,351 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.file;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Supplier;
+import org.apache.hop.core.gui.AreaOwner;
+import org.apache.hop.core.gui.IUndo;
+import org.apache.hop.core.gui.Point;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.gui.GuiMenuWidgets;
+import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.hopgui.HopGuiKeyHandler;
+import org.apache.hop.ui.hopgui.perspective.execution.DragViewZoomBase;
+import org.apache.hop.ui.testing.SwtBotTestBase;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.widgets.Canvas;
+import org.eclipse.swt.widgets.Event;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swtbot.swt.finder.SWTBot;
+
+/**
+ * Drives a pipeline or workflow canvas with real mouse events, so the whole
+ * mouseDown/mouseMove/mouseUp state machine of the graph runs unchanged.
+ *
+ * <p>The canvas is painted by hand rather than addressed through SWTBot's
widget matchers: the
+ * graph is a single custom-painted {@link Canvas}, so the only handle on a
transform or action is
+ * the clickable area the painter registers for it.
+ */
+public abstract class GraphCanvasTestBase extends SwtBotTestBase {
+
+ /** Screen distance used to probe the canvas-to-graph coordinate scale. */
+ private static final int SCALE_PROBE_PIXELS = 1000;
+
+ /** How long the helpers wait for a paint, a dialog or a dialog closing. */
+ private static final int POLL_ATTEMPTS = 40;
+
+ private static final int POLL_MILLIS = 50;
+
+ /**
+ * The Hop GUI event loop logs and swallows whatever a mouse handler throws,
which is why a broken
+ * canvas shows no error dialog. Mirror that, and assert on the collected
failures instead.
+ */
+ protected final List<Throwable> swallowed = new CopyOnWriteArrayList<>();
+
+ /** Looks up the clickable area a graph registered at a graph coordinate. */
+ @FunctionalInterface
+ protected interface AreaLookup {
+ AreaOwner at(int x, int y);
+ }
+
+ // ---------------------------------------------------------------- mouse
events
+
+ /**
+ * Dispatches one mouse event on the canvas, converting the graph coordinate
to a canvas pixel,
+ * and waits for the handler to finish.
+ */
+ protected void fire(
+ Canvas canvas, int type, double scale, Point graphPoint, int button, int
stateMask) {
+ Event event = mouseEvent(scale, graphPoint, button, stateMask);
+ display.syncExec(() -> dispatch(canvas, type, event));
+ }
+
+ /**
+ * Same as {@link #fire} but without waiting. Use it for an event that may
open a dialog: a dialog
+ * runs its own event loop and would otherwise block this worker inside the
event dispatch.
+ */
+ protected void fireAsync(
+ Canvas canvas, int type, double scale, Point graphPoint, int button, int
stateMask) {
+ Event event = mouseEvent(scale, graphPoint, button, stateMask);
+ display.asyncExec(() -> dispatch(canvas, type, event));
+ }
+
+ private void dispatch(Canvas canvas, int type, Event event) {
+ try {
+ canvas.notifyListeners(type, event);
+ } catch (Throwable t) {
+ swallowed.add(t);
+ }
+ }
+
+ private static Event mouseEvent(double scale, Point graphPoint, int button,
int stateMask) {
+ Event event = new Event();
+ event.button = button;
+ event.stateMask = stateMask;
+ event.count = 1;
+ event.x = (int) Math.round(graphPoint.x * scale);
+ event.y = (int) Math.round(graphPoint.y * scale);
+ return event;
+ }
+
+ // ---------------------------------------------------------------- the Hop
GUI
+
+ private static HopGui hopGui;
+
+ /**
+ * The Hop GUI the graphs under test hang off. Menus and toolbars are the
only thing missing: they
+ * are built when the real application shell opens, which a test never does.
Call it on the UI
+ * thread.
+ */
+ protected static synchronized HopGui hopGui() {
+ if (hopGui == null) {
+ hopGui = new MenulessHopGui();
+ hopGui.setProps(PropsUi.getInstance());
+ }
+ return hopGui;
+ }
+
+ /**
+ * A Hop GUI whose main menu was never built - it is created when the
application shell opens,
+ * which a test never does. Without this, refreshing the Edit menu after an
undo entry throws,
+ * which aborts the very canvas handler under test half way and would be
read as a defect. The
+ * graphs themselves are the real ones.
+ */
+ private static class MenulessHopGui extends HopGui {
+ private final GuiMenuWidgets emptyMenu = new GuiMenuWidgets();
+
+ @Override
+ public GuiMenuWidgets getMainMenuWidgets() {
+ return emptyMenu;
+ }
+
+ @Override
+ public void setUndoMenu(IUndo undoInterface) {
+ // reads the menu widgets field directly, so it needs its own no-op
+ }
+
+ @Override
+ public void handleFileCapabilities(
+ IHopFileType fileType,
+ IHopFileTypeHandler handler,
+ boolean changed,
+ boolean running,
+ boolean paused) {
+ // reads the menu widgets field directly, so it needs its own no-op
+ }
+ }
+
+ // ---------------------------------------------------------------- keyboard
+
+ /**
+ * Hooks the real Hop GUI key handler up to the test shell. The graph
registered itself as a
+ * shortcut target in its constructor, but the handler was attached to the
application shell,
+ * which is not the one holding the canvas here.
+ */
+ protected static void attachKeyboardShortcuts(Shell shell) {
+ hopGui().replaceKeyboardShortcutListeners(shell,
HopGuiKeyHandler.getInstance());
+ }
+
+ /** Presses a key on the canvas, e.g. {@code SWT.ESC}. */
+ protected void fireKey(Canvas canvas, int keyCode) {
+ Event event = new Event();
+ event.keyCode = keyCode;
+ event.character = (char) keyCode;
+ event.doit = true;
+ display.syncExec(() -> dispatch(canvas, SWT.KeyDown, event));
+ }
+
+ // ----------------------------------------------------------------
coordinates
+
+ /**
+ * Canvas pixels per graph unit. Derived through the public {@code
screen2real} so the tests do
+ * not have to know about zoom factors or high-DPI scaling.
+ */
+ protected double canvasToGraphScale(DragViewZoomBase graph) {
+ Point origin = onUi(() -> graph.screen2real(0, 0));
+ Point far = onUi(() -> graph.screen2real(SCALE_PROBE_PIXELS, 0));
+ return (double) SCALE_PROBE_PIXELS / (far.x - origin.x);
+ }
+
+ protected static Point midpoint(Point from, Point to) {
+ return new Point((from.x + to.x) / 2, (from.y + to.y) / 2);
+ }
+
+ /**
+ * Waits for the first paint - the painter is what registers the clickable
areas - and returns the
+ * centre of the icon of {@code owner} in graph coordinates.
+ */
+ protected Point awaitIcon(
+ SWTBot bot, AreaLookup lookup, AreaOwner.AreaType iconType, Object
owner, Point location) {
+ int iconSize = onUi(() -> PropsUi.getInstance().getIconSize());
+ Point centre = new Point(location.x + iconSize / 2, location.y + iconSize
/ 2);
+ for (int attempt = 0; attempt < POLL_ATTEMPTS; attempt++) {
+ AreaOwner areaOwner = onUi(() -> lookup.at(centre.x, centre.y));
+ if (areaOwner != null) {
+ assertEquals(iconType, areaOwner.getAreaType(), "expected to aim at
the icon");
+ assertSame(owner, areaOwner.getOwner(), "expected to aim at " + owner);
+ return centre;
+ }
+ bot.sleep(POLL_MILLIS);
+ }
+ throw new AssertionError("the canvas never painted the icon of " + owner);
+ }
+
+ /** Guards a test's own assumption that a spot really is empty canvas. */
+ protected void assertEmptyCanvas(AreaLookup lookup, Point... points) {
+ for (Point point : points) {
+ assertTrue(
+ onUi(() -> lookup.at(point.x, point.y)) == null, "expected empty
canvas at " + point);
+ }
+ }
+
+ // ---------------------------------------------------------------- dialogs
+
+ protected Set<Shell> openShells() {
+ return onUi(() -> new HashSet<>(Arrays.asList(display.getShells())));
+ }
+
+ /** Polls for a shell that was not open before, e.g. a context dialog. */
+ protected Shell awaitNewShell(SWTBot bot, Set<Shell> before) {
+ for (int attempt = 0; attempt < POLL_ATTEMPTS; attempt++) {
+ Shell found =
+ onUi(
+ () -> {
+ for (Shell shell : display.getShells()) {
+ if (!shell.isDisposed() && !before.contains(shell)) {
+ return shell;
+ }
+ }
+ return null;
+ });
+ if (found != null) {
+ return found;
+ }
+ bot.sleep(POLL_MILLIS);
+ }
+ return null;
+ }
+
+ /** Closes a dialog that opened, so its event loop hands the UI thread back.
*/
+ protected void closeShell(SWTBot bot, Shell shell) {
+ if (shell == null) {
+ return;
+ }
+ display.asyncExec(
+ () -> {
+ if (!shell.isDisposed()) {
+ shell.close();
+ }
+ });
+ for (int attempt = 0; attempt < POLL_ATTEMPTS && !onUi(shell::isDisposed);
attempt++) {
+ bot.sleep(POLL_MILLIS);
+ }
+ display.asyncExec(
+ () -> {
+ if (!shell.isDisposed()) {
+ shell.dispose();
+ }
+ });
+ }
+
+ /** The title of a dialog that opened, or null when nothing opened. */
+ protected String titleOf(Shell shell) {
+ return shell == null ? null : onUi(shell::getText);
+ }
+
+ // ---------------------------------------------------------------- graph
state
+
+ /**
+ * Asserts that every named field holds its expected idle value, reporting
all offenders at once.
+ * The graph keeps the state of a gesture in private fields; once the
gesture is over they have to
+ * be back to their initial value, or the next gesture starts from a
half-finished one.
+ */
+ protected static void assertGraphState(Object graph, Map<String, Object>
expected) {
+ List<String> stale = new ArrayList<>();
+ expected.forEach(
+ (name, want) -> {
+ Object actual = privateField(graph, name);
+ if (want == null ? actual != null : !want.equals(actual)) {
+ stale.add(name + " = " + describe(actual) + " (expected " +
describe(want) + ")");
+ }
+ });
+ assertTrue(stale.isEmpty(), "the canvas kept state from the finished
gesture: " + stale);
+ }
+
+ private static String describe(Object value) {
+ return value == null ? "null" : value.toString();
+ }
+
+ /** Reads a private field of the graph so a test can assert on the state of
a gesture. */
+ protected static Object privateField(Object target, String name) {
+ for (Class<?> type = target.getClass(); type != null; type =
type.getSuperclass()) {
+ try {
+ Field field = type.getDeclaredField(name);
+ field.setAccessible(true);
+ return field.get(target);
+ } catch (NoSuchFieldException e) {
+ // keep walking up the hierarchy
+ } catch (IllegalAccessException e) {
+ throw new AssertionError("Could not read " + type.getSimpleName() +
"." + name, e);
+ }
+ }
+ throw new AssertionError("No field '" + name + "' on " +
target.getClass());
+ }
+
+ // ---------------------------------------------------------------- UI thread
+
+ /** Runs {@code supplier} on the UI thread and hands its result back to the
SWTBot worker. */
+ protected static <T> T onUi(Supplier<T> supplier) {
+ AtomicReference<T> result = new AtomicReference<>();
+ AtomicReference<RuntimeException> failure = new AtomicReference<>();
+ display.syncExec(
+ () -> {
+ try {
+ result.set(supplier.get());
+ } catch (RuntimeException e) {
+ failure.set(e);
+ }
+ });
+ if (failure.get() != null) {
+ throw failure.get();
+ }
+ return result.get();
+ }
+
+ /** Runs {@code runnable} on the UI thread and waits for it. */
+ protected static void onUi(Runnable runnable) {
+ onUi(
+ () -> {
+ runnable.run();
+ return null;
+ });
+ }
+}
diff --git
a/rcp/src/test/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraphHopCreationTest.java
b/rcp/src/test/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraphHopCreationTest.java
new file mode 100644
index 0000000000..ee21a99d9b
--- /dev/null
+++
b/rcp/src/test/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraphHopCreationTest.java
@@ -0,0 +1,628 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.file.pipeline;
+
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.gui.AreaOwner;
+import org.apache.hop.core.gui.Point;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transform.BaseTransformMeta;
+import org.apache.hop.pipeline.transform.ITransform;
+import org.apache.hop.pipeline.transform.ITransformData;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.hopgui.HopGuiEnvironment;
+import org.apache.hop.ui.hopgui.file.GraphCanvasTestBase;
+import
org.apache.hop.ui.hopgui.file.pipeline.context.HopGuiPipelineTransformContext;
+import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerPerspective;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.graphics.Rectangle;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.widgets.Canvas;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swtbot.swt.finder.SWTBot;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Every way a user can draw a hop on the pipeline canvas, and the ways to
abandon one. Each test
+ * replays the gesture as real mouse events and then checks three things: the
hop is there (or is
+ * not), no dialog appeared that should not have, and the canvas is back in
its idle state so the
+ * next gesture does not start half-finished.
+ *
+ * <p>Covers <a href="https://github.com/apache/hop/issues/7768">issue
#7768</a>.
+ */
+@Tag("uitest")
+class HopGuiPipelineGraphHopCreationTest extends GraphCanvasTestBase {
+
+ private static final String SOURCE_TRANSFORM = "Query Delete";
+ private static final String TARGET_TRANSFORM = "Write to log";
+ private static final Point SOURCE_LOCATION = new Point(60, 60);
+ private static final Point TARGET_LOCATION = new Point(280, 60);
+
+ @BeforeAll
+ static void registerGuiPlugins() throws HopException {
+ // A context dialog bails out when the GUI registry holds no actions for
the context, so
+ // register the GUI plugins to get the same dialogs the user sees.
+ HopGuiEnvironment.init();
+ }
+
+ // ------------------------------------------------------------------ create
hop from the menu
+
+ @Test
+ void createHopActionThenClickingTheTargetCreatesTheHop() {
+ onCanvas(
+ (bot, graph, pipelineMeta, spots) -> {
+ TransformMeta source = pipelineMeta.findTransform(SOURCE_TRANSFORM);
+ TransformMeta target = pipelineMeta.findTransform(TARGET_TRANSFORM);
+
+ clickTransformAndPickCreateHop(graph, spots, source);
+
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.target, 0,
SWT.NONE);
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.target, 1,
SWT.NONE);
+ String popup = releaseAndCatchDialog(bot, spots, spots.target,
SWT.BUTTON1);
+
+ assertAll(
+ () -> assertHopExists(pipelineMeta, source, target),
+ () -> assertNoDialog(popup),
+ () -> assertNoFailures(),
+ () -> assertCanvasIsIdle(graph));
+ });
+ }
+
+ @Test
+ void createHopActionThenClickingEmptyCanvasCancelsTheHop() {
+ onCanvas(
+ (bot, graph, pipelineMeta, spots) -> {
+ TransformMeta source = pipelineMeta.findTransform(SOURCE_TRANSFORM);
+ TransformMeta target = pipelineMeta.findTransform(TARGET_TRANSFORM);
+ Point sourceWas = new Point(source.getLocation());
+
+ clickTransformAndPickCreateHop(graph, spots, source);
+
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyA, 0,
SWT.NONE);
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.emptyA, 1,
SWT.NONE);
+ String popup = releaseAndCatchDialog(bot, spots, spots.emptyA,
SWT.BUTTON1);
+
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyB, 0,
SWT.NONE);
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyC, 0,
SWT.NONE);
+
+ assertAll(
+ () -> assertNoHop(pipelineMeta, source, target),
+ () -> assertNoDialog(popup),
+ () -> assertNoFailures(),
+ () -> assertDidNotMove(sourceWas, source),
+ () -> assertCanvasIsIdle(graph));
+ });
+ }
+
+ // ------------------------------------------------------------------ shift
click, shift click
+
+ @Test
+ void shiftClickingSourceAndTargetCreatesTheHop() {
+ onCanvas(
+ (bot, graph, pipelineMeta, spots) -> {
+ TransformMeta source = pipelineMeta.findTransform(SOURCE_TRANSFORM);
+ TransformMeta target = pipelineMeta.findTransform(TARGET_TRANSFORM);
+
+ // SHIFT stays down for the whole gesture.
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 1,
SWT.SHIFT);
+ fire(spots.canvas, SWT.MouseUp, spots.scale, spots.source, 1,
SWT.SHIFT | SWT.BUTTON1);
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.target, 0,
SWT.SHIFT);
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.target, 1,
SWT.SHIFT);
+ String popup = releaseAndCatchDialog(bot, spots, spots.target,
SWT.SHIFT | SWT.BUTTON1);
+
+ assertAll(
+ () -> assertHopExists(pipelineMeta, source, target),
+ () -> assertNoDialog(popup),
+ () -> assertNoFailures(),
+ () -> assertCanvasIsIdle(graph));
+ });
+ }
+
+ // ------------------------------------------------------------------ shift
click, plain click
+
+ @Test
+ void shiftClickingSourceThenPlainClickingTargetCreatesTheHop() {
+ onCanvas(
+ (bot, graph, pipelineMeta, spots) -> {
+ TransformMeta source = pipelineMeta.findTransform(SOURCE_TRANSFORM);
+ TransformMeta target = pipelineMeta.findTransform(TARGET_TRANSFORM);
+
+ // SHIFT is released after starting the hop.
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 1,
SWT.SHIFT);
+ fire(spots.canvas, SWT.MouseUp, spots.scale, spots.source, 1,
SWT.SHIFT | SWT.BUTTON1);
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.target, 0,
SWT.NONE);
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.target, 1,
SWT.NONE);
+ String popup = releaseAndCatchDialog(bot, spots, spots.target,
SWT.BUTTON1);
+
+ assertAll(
+ () -> assertHopExists(pipelineMeta, source, target),
+ () -> assertNoDialog(popup),
+ () -> assertNoFailures(),
+ () -> assertCanvasIsIdle(graph));
+ });
+ }
+
+ @Test
+ void shiftClickingSourceThenClickingEmptyCanvasCancelsTheHop() {
+ onCanvas(
+ (bot, graph, pipelineMeta, spots) -> {
+ TransformMeta source = pipelineMeta.findTransform(SOURCE_TRANSFORM);
+ TransformMeta target = pipelineMeta.findTransform(TARGET_TRANSFORM);
+ Point sourceWas = new Point(source.getLocation());
+
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 1,
SWT.SHIFT);
+ fire(spots.canvas, SWT.MouseUp, spots.scale, spots.source, 1,
SWT.SHIFT | SWT.BUTTON1);
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyA, 0,
SWT.NONE);
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.emptyA, 1,
SWT.NONE);
+ String popup = releaseAndCatchDialog(bot, spots, spots.emptyA,
SWT.BUTTON1);
+
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyB, 0,
SWT.NONE);
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyC, 0,
SWT.NONE);
+
+ assertAll(
+ () -> assertNoHop(pipelineMeta, source, target),
+ () -> assertNoDialog(popup),
+ () -> assertNoFailures(),
+ () -> assertDidNotMove(sourceWas, source),
+ () -> assertCanvasIsIdle(graph));
+ });
+ }
+
+ // ------------------------------------------------------------------ shift
drag
+
+ @Test
+ void shiftDraggingOntoTheTargetCreatesTheHop() {
+ onCanvas(
+ (bot, graph, pipelineMeta, spots) -> {
+ TransformMeta source = pipelineMeta.findTransform(SOURCE_TRANSFORM);
+ TransformMeta target = pipelineMeta.findTransform(TARGET_TRANSFORM);
+
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 1,
SWT.SHIFT);
+ fire(
+ spots.canvas,
+ SWT.MouseMove,
+ spots.scale,
+ midpoint(spots.source, spots.target),
+ 0,
+ SWT.SHIFT | SWT.BUTTON1);
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.target, 0,
SWT.SHIFT | SWT.BUTTON1);
+ String popup = releaseAndCatchDialog(bot, spots, spots.target,
SWT.SHIFT | SWT.BUTTON1);
+
+ assertAll(
+ () -> assertHopExists(pipelineMeta, source, target),
+ () -> assertNoDialog(popup),
+ () -> assertNoFailures(),
+ () -> assertCanvasIsIdle(graph));
+ });
+ }
+
+ /** Issue #7768: the drag is abandoned over empty canvas. */
+ @Test
+ void shiftDraggingOntoEmptyCanvasCancelsTheHop() {
+ onCanvas(
+ (bot, graph, pipelineMeta, spots) -> {
+ TransformMeta source = pipelineMeta.findTransform(SOURCE_TRANSFORM);
+ TransformMeta target = pipelineMeta.findTransform(TARGET_TRANSFORM);
+ Point sourceWas = new Point(source.getLocation());
+
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 1,
SWT.SHIFT);
+ fire(
+ spots.canvas,
+ SWT.MouseMove,
+ spots.scale,
+ midpoint(spots.source, spots.emptyA),
+ 0,
+ SWT.SHIFT | SWT.BUTTON1);
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyA, 0,
SWT.SHIFT | SWT.BUTTON1);
+ fire(spots.canvas, SWT.MouseUp, spots.scale, spots.emptyA, 1,
SWT.SHIFT | SWT.BUTTON1);
+
+ Object pendingHop = privateField(graph, "startHopTransform");
+
+ // Click somewhere else and move on, as in the report. Once the
release above has
+ // cancelled the hop this is an ordinary click on empty canvas, so
the dialog offering to
+ // create something here is expected - it is dismissed again right
away.
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyB, 0,
SWT.NONE);
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.emptyB, 1,
SWT.NONE);
+ releaseAndCatchDialog(bot, spots, spots.emptyB, SWT.BUTTON1);
+ fire(
+ spots.canvas, SWT.MouseMove, spots.scale, midpoint(spots.emptyB,
spots.emptyC), 0, 0);
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyC, 0,
SWT.NONE);
+
+ assertAll(
+ () ->
+ assertNull(
+ pendingHop,
+ "releasing over empty canvas must cancel the pending hop
right away"),
+ () -> assertNoHop(pipelineMeta, source, target),
+ () -> assertNoFailures(),
+ () -> assertDidNotMove(sourceWas, source),
+ () -> assertCanvasIsIdle(graph));
+ });
+ }
+
+ // ------------------------------------------------------------------ middle
button
+
+ @Test
+ void middleDraggingOntoTheTargetCreatesTheHop() {
+ onCanvas(
+ (bot, graph, pipelineMeta, spots) -> {
+ TransformMeta source = pipelineMeta.findTransform(SOURCE_TRANSFORM);
+ TransformMeta target = pipelineMeta.findTransform(TARGET_TRANSFORM);
+
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 2,
SWT.NONE);
+ fire(
+ spots.canvas,
+ SWT.MouseMove,
+ spots.scale,
+ midpoint(spots.source, spots.target),
+ 0,
+ SWT.BUTTON2);
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.target, 0,
SWT.BUTTON2);
+ String popup = releaseAndCatchDialog(bot, spots, spots.target, 2,
SWT.BUTTON2);
+
+ assertAll(
+ () -> assertHopExists(pipelineMeta, source, target),
+ () -> assertNoDialog(popup),
+ () -> assertNoFailures(),
+ () -> assertCanvasIsIdle(graph));
+ });
+ }
+
+ @Test
+ void middleDraggingOntoEmptyCanvasCancelsTheHop() {
+ onCanvas(
+ (bot, graph, pipelineMeta, spots) -> {
+ TransformMeta source = pipelineMeta.findTransform(SOURCE_TRANSFORM);
+ TransformMeta target = pipelineMeta.findTransform(TARGET_TRANSFORM);
+ Point sourceWas = new Point(source.getLocation());
+
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 2,
SWT.NONE);
+ fire(
+ spots.canvas,
+ SWT.MouseMove,
+ spots.scale,
+ midpoint(spots.source, spots.emptyA),
+ 0,
+ SWT.BUTTON2);
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyA, 0,
SWT.BUTTON2);
+ fire(spots.canvas, SWT.MouseUp, spots.scale, spots.emptyA, 2,
SWT.BUTTON2);
+ Object pendingHop = privateField(graph, "startHopTransform");
+
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyB, 0,
SWT.NONE);
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyC, 0,
SWT.NONE);
+
+ assertAll(
+ () ->
+ assertNull(
+ pendingHop,
+ "releasing over empty canvas must cancel the pending hop
right away"),
+ () -> assertNoHop(pipelineMeta, source, target),
+ () -> assertNoFailures(),
+ () -> assertDidNotMove(sourceWas, source),
+ () -> assertCanvasIsIdle(graph));
+ });
+ }
+
+ @Test
+ void middleClickingSourceThenClickingTargetCreatesTheHop() {
+ onCanvas(
+ (bot, graph, pipelineMeta, spots) -> {
+ TransformMeta source = pipelineMeta.findTransform(SOURCE_TRANSFORM);
+ TransformMeta target = pipelineMeta.findTransform(TARGET_TRANSFORM);
+
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 2,
SWT.NONE);
+ fire(spots.canvas, SWT.MouseUp, spots.scale, spots.source, 2,
SWT.BUTTON2);
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.target, 0,
SWT.NONE);
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.target, 1,
SWT.NONE);
+ String popup = releaseAndCatchDialog(bot, spots, spots.target,
SWT.BUTTON1);
+
+ assertAll(
+ () -> assertHopExists(pipelineMeta, source, target),
+ () -> assertNoDialog(popup),
+ () -> assertNoFailures(),
+ () -> assertCanvasIsIdle(graph));
+ });
+ }
+
+ @Test
+ void middleClickingSourceThenClickingEmptyCanvasCancelsTheHop() {
+ onCanvas(
+ (bot, graph, pipelineMeta, spots) -> {
+ TransformMeta source = pipelineMeta.findTransform(SOURCE_TRANSFORM);
+ TransformMeta target = pipelineMeta.findTransform(TARGET_TRANSFORM);
+ Point sourceWas = new Point(source.getLocation());
+
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 2,
SWT.NONE);
+ fire(spots.canvas, SWT.MouseUp, spots.scale, spots.source, 2,
SWT.BUTTON2);
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyA, 0,
SWT.NONE);
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.emptyA, 1,
SWT.NONE);
+ String popup = releaseAndCatchDialog(bot, spots, spots.emptyA,
SWT.BUTTON1);
+
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyB, 0,
SWT.NONE);
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyC, 0,
SWT.NONE);
+
+ assertAll(
+ () -> assertNoHop(pipelineMeta, source, target),
+ () -> assertNoDialog(popup),
+ () -> assertNoFailures(),
+ () -> assertDidNotMove(sourceWas, source),
+ () -> assertCanvasIsIdle(graph));
+ });
+ }
+
+ // ------------------------------------------------------------------ escape
and right click
+
+ @Test
+ void escapeCancelsThePendingHop() {
+ onCanvas(
+ (bot, graph, pipelineMeta, spots) -> {
+ TransformMeta source = pipelineMeta.findTransform(SOURCE_TRANSFORM);
+ TransformMeta target = pipelineMeta.findTransform(TARGET_TRANSFORM);
+ Point sourceWas = new Point(source.getLocation());
+
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 1,
SWT.SHIFT);
+ fire(spots.canvas, SWT.MouseUp, spots.scale, spots.source, 1,
SWT.SHIFT | SWT.BUTTON1);
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyA, 0,
SWT.NONE);
+ assertNotNull(
+ privateField(graph, "startHopTransform"), "the hop should be
pending by now");
+
+ fireKey(spots.canvas, SWT.ESC);
+ Object pendingHop = privateField(graph, "startHopTransform");
+
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyB, 0,
SWT.NONE);
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyC, 0,
SWT.NONE);
+
+ assertAll(
+ () -> assertNull(pendingHop, "escape must cancel the pending
hop"),
+ () -> assertNoHop(pipelineMeta, source, target),
+ () -> assertNoFailures(),
+ () -> assertDidNotMove(sourceWas, source),
+ () -> assertCanvasIsIdle(graph));
+ });
+ }
+
+ @Test
+ void rightClickingEmptyCanvasCancelsThePendingHop() {
+ onCanvas(
+ (bot, graph, pipelineMeta, spots) -> {
+ TransformMeta source = pipelineMeta.findTransform(SOURCE_TRANSFORM);
+ TransformMeta target = pipelineMeta.findTransform(TARGET_TRANSFORM);
+ Point sourceWas = new Point(source.getLocation());
+
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 1,
SWT.SHIFT);
+ fire(spots.canvas, SWT.MouseUp, spots.scale, spots.source, 1,
SWT.SHIFT | SWT.BUTTON1);
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyA, 0,
SWT.NONE);
+
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.emptyA, 3,
SWT.NONE);
+ String popup = releaseAndCatchDialog(bot, spots, spots.emptyA, 3,
SWT.BUTTON3);
+
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyB, 0,
SWT.NONE);
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyC, 0,
SWT.NONE);
+
+ assertAll(
+ () -> assertNoHop(pipelineMeta, source, target),
+ () -> assertNoDialog(popup),
+ () -> assertNoFailures(),
+ () -> assertDidNotMove(sourceWas, source),
+ () -> assertCanvasIsIdle(graph));
+ });
+ }
+
+ // ------------------------------------------------------------------
gesture building blocks
+
+ /**
+ * Clicks the transform and runs the "Create hop" item of the context dialog
that pops up. Only
+ * the picking of the item is short-circuited - the click that opens the
dialog is a real one, so
+ * the state it leaves behind is real too.
+ */
+ private void clickTransformAndPickCreateHop(
+ HopGuiPipelineGraph graph, Spots spots, TransformMeta transform) {
+ // Defer the context dialog and cancel it, the way a following click does,
so the test does
+ // not have to drive the dialog itself.
+ onUi(() -> PropsUi.getInstance().setUseDoubleClickOnCanvas(true));
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 1, SWT.NONE);
+ fire(spots.canvas, SWT.MouseUp, spots.scale, spots.source, 1, SWT.BUTTON1);
+ cancelPendingContextDialog(graph);
+ onUi(() -> PropsUi.getInstance().setUseDoubleClickOnCanvas(false));
+
+ onUi(
+ () ->
+ graph.newHopCandidate(
+ new HopGuiPipelineTransformContext(
+ graph.getPipelineMeta(), transform, graph, new
Point(spots.source))));
+ }
+
+ private void cancelPendingContextDialog(HopGuiPipelineGraph graph) {
+ Object pending = privateField(graph, "pendingShowActionDialogRunnable");
+ if (pending instanceof Runnable runnable) {
+ onUi(() -> display.timerExec(-1, runnable));
+ }
+ }
+
+ /**
+ * Releases the mouse button and reports the title of the dialog that
opened, or null when none
+ * did. Any dialog is closed again so the canvas event loop is handed back.
+ */
+ private String releaseAndCatchDialog(SWTBot bot, Spots spots, Point at, int
stateMask) {
+ return releaseAndCatchDialog(bot, spots, at, 1, stateMask);
+ }
+
+ private String releaseAndCatchDialog(
+ SWTBot bot, Spots spots, Point at, int button, int stateMask) {
+ Set<Shell> before = openShells();
+ fireAsync(spots.canvas, SWT.MouseUp, spots.scale, at, button, stateMask);
+ Shell popup = awaitNewShell(bot, before);
+ String title = titleOf(popup);
+ closeShell(bot, popup);
+ return title;
+ }
+
+ // ------------------------------------------------------------------
assertions
+
+ private void assertHopExists(PipelineMeta meta, TransformMeta from,
TransformMeta to) {
+ assertNotNull(
+ meta.findPipelineHop(from, to),
+ "the gesture should have created the hop " + from + " → " + to);
+ }
+
+ private void assertNoHop(PipelineMeta meta, TransformMeta from,
TransformMeta to) {
+ assertNull(meta.findPipelineHop(from, to), "the abandoned gesture must not
create a hop");
+ }
+
+ private void assertNoDialog(String dialogTitle) {
+ assertNull(dialogTitle, "no dialog may open here, but one did");
+ }
+
+ private void assertNoFailures() {
+ assertTrue(swallowed.isEmpty(), "the canvas must not throw, but got " +
swallowed);
+ }
+
+ private static void assertDidNotMove(Point was, TransformMeta transform) {
+ assertEquals(
+ was.toString(),
+ transform.getLocation().toString(),
+ "the transform followed the mouse pointer");
+ }
+
+ /** Everything the graph remembers about a gesture has to be back to its
initial value. */
+ private static void assertCanvasIsIdle(HopGuiPipelineGraph graph) {
+ Map<String, Object> idle = new LinkedHashMap<>();
+ idle.put("startHopTransform", null);
+ idle.put("endHopTransform", null);
+ idle.put("endHopLocation", null);
+ idle.put("candidate", null);
+ idle.put("currentTransform", null);
+ idle.put("selectedTransform", null);
+ idle.put("selectedTransforms", null);
+ idle.put("selectionRegion", null);
+ idle.put("splitHop", false);
+ idle.put("dragSelection", false);
+ idle.put("avoidContextDialog", false);
+ idle.put("lastButton", 0);
+ assertGraphState(graph, idle);
+ }
+
+ // ------------------------------------------------------------------ scene
+
+ /** What a test needs to aim at: the canvas, its scale and the interesting
graph coordinates. */
+ private record Spots(
+ Canvas canvas,
+ double scale,
+ Point source,
+ Point target,
+ Point emptyA,
+ Point emptyB,
+ Point emptyC) {}
+
+ @FunctionalInterface
+ private interface CanvasTest {
+ void run(SWTBot bot, HopGuiPipelineGraph graph, PipelineMeta pipelineMeta,
Spots spots);
+ }
+
+ private void onCanvas(CanvasTest test) {
+ AtomicReference<HopGuiPipelineGraph> graphRef = new AtomicReference<>();
+ AtomicReference<PipelineMeta> metaRef = new AtomicReference<>();
+
+ withScene(
+ shell -> {
+ shell.setSize(1000, 700);
+ shell.setLayout(new FormLayout());
+ // Keep the single click on the canvas synchronous and deterministic.
+ PropsUi.getInstance().setUseDoubleClickOnCanvas(false);
+
+ PipelineMeta pipelineMeta = buildPipeline();
+ metaRef.set(pipelineMeta);
+ graphRef.set(
+ new HopGuiPipelineGraph(
+ shell,
+ hopGui(),
+ new ExplorerPerspective(),
+ pipelineMeta,
+ new HopPipelineFileType<>()));
+ attachKeyboardShortcuts(shell);
+ },
+ bot -> {
+ HopGuiPipelineGraph graph = graphRef.get();
+ PipelineMeta pipelineMeta = metaRef.get();
+ test.run(bot, graph, pipelineMeta, aim(bot, graph, pipelineMeta));
+ });
+ }
+
+ /** Locates both icons and three spots of empty canvas, clear of the icons
and of the minimap. */
+ private Spots aim(SWTBot bot, HopGuiPipelineGraph graph, PipelineMeta
pipelineMeta) {
+ Canvas canvas = onUi(graph::getCanvas);
+ double scale = canvasToGraphScale(graph);
+ AreaLookup lookup = graph::getVisibleAreaOwner;
+
+ Point source =
+ awaitIcon(
+ bot,
+ lookup,
+ AreaOwner.AreaType.TRANSFORM_ICON,
+ pipelineMeta.findTransform(SOURCE_TRANSFORM),
+ SOURCE_LOCATION);
+ Point target =
+ awaitIcon(
+ bot,
+ lookup,
+ AreaOwner.AreaType.TRANSFORM_ICON,
+ pipelineMeta.findTransform(TARGET_TRANSFORM),
+ TARGET_LOCATION);
+
+ Rectangle bounds = onUi(canvas::getBounds);
+ Point visible = onUi(() -> graph.screen2real(bounds.width, bounds.height));
+ Point emptyA = new Point((int) (visible.x * 0.50), (int) (visible.y *
0.45));
+ Point emptyB = new Point((int) (visible.x * 0.30), (int) (visible.y *
0.60));
+ Point emptyC = new Point((int) (visible.x * 0.20), (int) (visible.y *
0.72));
+ assertEmptyCanvas(lookup, emptyA, emptyB, emptyC);
+
+ return new Spots(canvas, scale, source, target, emptyA, emptyB, emptyC);
+ }
+
+ /** Two transforms, far enough apart to aim at either icon, with no hop
between them yet. */
+ private static PipelineMeta buildPipeline() {
+ PipelineMeta pipelineMeta = new PipelineMeta();
+ pipelineMeta.setName("hop-creation");
+
+ TransformMeta source = transform(SOURCE_TRANSFORM, SOURCE_LOCATION);
+ TransformMeta target = transform(TARGET_TRANSFORM, TARGET_LOCATION);
+ pipelineMeta.addTransform(source);
+ pipelineMeta.addTransform(target);
+ return pipelineMeta;
+ }
+
+ private static TransformMeta transform(String name, Point location) {
+ TransformMeta transformMeta =
+ new TransformMeta("Dummy", name, new BaseTransformMeta<ITransform,
ITransformData>());
+ transformMeta.setLocation(location.x, location.y);
+ return transformMeta;
+ }
+}
diff --git
a/rcp/src/test/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraphHopCreationTest.java
b/rcp/src/test/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraphHopCreationTest.java
new file mode 100644
index 0000000000..ef32ce21f7
--- /dev/null
+++
b/rcp/src/test/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraphHopCreationTest.java
@@ -0,0 +1,642 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.file.workflow;
+
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.hop.core.Result;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.gui.AreaOwner;
+import org.apache.hop.core.gui.Point;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.hopgui.HopGuiEnvironment;
+import org.apache.hop.ui.hopgui.file.GraphCanvasTestBase;
+import
org.apache.hop.ui.hopgui.file.workflow.context.HopGuiWorkflowActionContext;
+import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerPerspective;
+import org.apache.hop.workflow.WorkflowMeta;
+import org.apache.hop.workflow.action.ActionBase;
+import org.apache.hop.workflow.action.ActionMeta;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.graphics.Rectangle;
+import org.eclipse.swt.layout.GridLayout;
+import org.eclipse.swt.widgets.Canvas;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swtbot.swt.finder.SWTBot;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Every way a user can draw a hop on the workflow canvas, and the ways to
abandon one - the
+ * workflow counterpart of the pipeline suite, gesture for gesture.
+ *
+ * <p>Covers <a href="https://github.com/apache/hop/issues/7768">issue
#7768</a>.
+ */
+@Tag("uitest")
+class HopGuiWorkflowGraphHopCreationTest extends GraphCanvasTestBase {
+
+ private static final String SOURCE_ACTION = "Query Delete";
+ private static final String TARGET_ACTION = "Write to log";
+ private static final Point SOURCE_LOCATION = new Point(60, 60);
+ private static final Point TARGET_LOCATION = new Point(280, 60);
+
+ @BeforeAll
+ static void registerGuiPlugins() throws HopException {
+ // A context dialog bails out when the GUI registry holds no actions for
the context, so
+ // register the GUI plugins to get the same dialogs the user sees.
+ HopGuiEnvironment.init();
+ }
+
+ // ------------------------------------------------------------------ create
hop from the menu
+
+ @Test
+ void createHopActionThenClickingTheTargetCreatesTheHop() {
+ onCanvas(
+ (bot, graph, workflowMeta, spots) -> {
+ ActionMeta source = workflowMeta.findAction(SOURCE_ACTION);
+ ActionMeta target = workflowMeta.findAction(TARGET_ACTION);
+
+ clickActionAndPickCreateHop(graph, spots, source);
+
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.target, 0,
SWT.NONE);
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.target, 1,
SWT.NONE);
+ String popup = releaseAndCatchDialog(bot, spots, spots.target,
SWT.BUTTON1);
+
+ assertAll(
+ () -> assertHopExists(workflowMeta, source, target),
+ () -> assertNoDialog(popup),
+ () -> assertNoFailures(),
+ () -> assertCanvasIsIdle(graph));
+ });
+ }
+
+ @Test
+ void createHopActionThenClickingEmptyCanvasCancelsTheHop() {
+ onCanvas(
+ (bot, graph, workflowMeta, spots) -> {
+ ActionMeta source = workflowMeta.findAction(SOURCE_ACTION);
+ ActionMeta target = workflowMeta.findAction(TARGET_ACTION);
+ Point sourceWas = new Point(source.getLocation());
+
+ clickActionAndPickCreateHop(graph, spots, source);
+
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyA, 0,
SWT.NONE);
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.emptyA, 1,
SWT.NONE);
+ String popup = releaseAndCatchDialog(bot, spots, spots.emptyA,
SWT.BUTTON1);
+
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyB, 0,
SWT.NONE);
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyC, 0,
SWT.NONE);
+
+ assertAll(
+ () -> assertNoHop(workflowMeta, source, target),
+ () -> assertNoDialog(popup),
+ () -> assertNoFailures(),
+ () -> assertDidNotMove(sourceWas, source),
+ () -> assertCanvasIsIdle(graph));
+ });
+ }
+
+ // ------------------------------------------------------------------ shift
click, shift click
+
+ @Test
+ void shiftClickingSourceAndTargetCreatesTheHop() {
+ onCanvas(
+ (bot, graph, workflowMeta, spots) -> {
+ ActionMeta source = workflowMeta.findAction(SOURCE_ACTION);
+ ActionMeta target = workflowMeta.findAction(TARGET_ACTION);
+
+ // SHIFT stays down for the whole gesture.
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 1,
SWT.SHIFT);
+ fire(spots.canvas, SWT.MouseUp, spots.scale, spots.source, 1,
SWT.SHIFT | SWT.BUTTON1);
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.target, 0,
SWT.SHIFT);
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.target, 1,
SWT.SHIFT);
+ String popup = releaseAndCatchDialog(bot, spots, spots.target,
SWT.SHIFT | SWT.BUTTON1);
+
+ assertAll(
+ () -> assertHopExists(workflowMeta, source, target),
+ () -> assertNoDialog(popup),
+ () -> assertNoFailures(),
+ () -> assertCanvasIsIdle(graph));
+ });
+ }
+
+ // ------------------------------------------------------------------ shift
click, plain click
+
+ @Test
+ void shiftClickingSourceThenPlainClickingTargetCreatesTheHop() {
+ onCanvas(
+ (bot, graph, workflowMeta, spots) -> {
+ ActionMeta source = workflowMeta.findAction(SOURCE_ACTION);
+ ActionMeta target = workflowMeta.findAction(TARGET_ACTION);
+
+ // SHIFT is released after starting the hop.
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 1,
SWT.SHIFT);
+ fire(spots.canvas, SWT.MouseUp, spots.scale, spots.source, 1,
SWT.SHIFT | SWT.BUTTON1);
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.target, 0,
SWT.NONE);
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.target, 1,
SWT.NONE);
+ String popup = releaseAndCatchDialog(bot, spots, spots.target,
SWT.BUTTON1);
+
+ assertAll(
+ () -> assertHopExists(workflowMeta, source, target),
+ () -> assertNoDialog(popup),
+ () -> assertNoFailures(),
+ () -> assertCanvasIsIdle(graph));
+ });
+ }
+
+ @Test
+ void shiftClickingSourceThenClickingEmptyCanvasCancelsTheHop() {
+ onCanvas(
+ (bot, graph, workflowMeta, spots) -> {
+ ActionMeta source = workflowMeta.findAction(SOURCE_ACTION);
+ ActionMeta target = workflowMeta.findAction(TARGET_ACTION);
+ Point sourceWas = new Point(source.getLocation());
+
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 1,
SWT.SHIFT);
+ fire(spots.canvas, SWT.MouseUp, spots.scale, spots.source, 1,
SWT.SHIFT | SWT.BUTTON1);
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyA, 0,
SWT.NONE);
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.emptyA, 1,
SWT.NONE);
+ String popup = releaseAndCatchDialog(bot, spots, spots.emptyA,
SWT.BUTTON1);
+
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyB, 0,
SWT.NONE);
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyC, 0,
SWT.NONE);
+
+ assertAll(
+ () -> assertNoHop(workflowMeta, source, target),
+ () -> assertNoDialog(popup),
+ () -> assertNoFailures(),
+ () -> assertDidNotMove(sourceWas, source),
+ () -> assertCanvasIsIdle(graph));
+ });
+ }
+
+ // ------------------------------------------------------------------ shift
drag
+
+ @Test
+ void shiftDraggingOntoTheTargetCreatesTheHop() {
+ onCanvas(
+ (bot, graph, workflowMeta, spots) -> {
+ ActionMeta source = workflowMeta.findAction(SOURCE_ACTION);
+ ActionMeta target = workflowMeta.findAction(TARGET_ACTION);
+
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 1,
SWT.SHIFT);
+ fire(
+ spots.canvas,
+ SWT.MouseMove,
+ spots.scale,
+ midpoint(spots.source, spots.target),
+ 0,
+ SWT.SHIFT | SWT.BUTTON1);
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.target, 0,
SWT.SHIFT | SWT.BUTTON1);
+ String popup = releaseAndCatchDialog(bot, spots, spots.target,
SWT.SHIFT | SWT.BUTTON1);
+
+ assertAll(
+ () -> assertHopExists(workflowMeta, source, target),
+ () -> assertNoDialog(popup),
+ () -> assertNoFailures(),
+ () -> assertCanvasIsIdle(graph));
+ });
+ }
+
+ /** Issue #7768: the drag is abandoned over empty canvas. */
+ @Test
+ void shiftDraggingOntoEmptyCanvasCancelsTheHop() {
+ onCanvas(
+ (bot, graph, workflowMeta, spots) -> {
+ ActionMeta source = workflowMeta.findAction(SOURCE_ACTION);
+ ActionMeta target = workflowMeta.findAction(TARGET_ACTION);
+ Point sourceWas = new Point(source.getLocation());
+
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 1,
SWT.SHIFT);
+ fire(
+ spots.canvas,
+ SWT.MouseMove,
+ spots.scale,
+ midpoint(spots.source, spots.emptyA),
+ 0,
+ SWT.SHIFT | SWT.BUTTON1);
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyA, 0,
SWT.SHIFT | SWT.BUTTON1);
+ fire(spots.canvas, SWT.MouseUp, spots.scale, spots.emptyA, 1,
SWT.SHIFT | SWT.BUTTON1);
+
+ Object pendingHop = privateField(graph, "startHopAction");
+
+ // Click somewhere else and move on, as in the report. Once the
release above has
+ // cancelled the hop this is an ordinary click on empty canvas, so
the dialog offering to
+ // create something here is expected - it is dismissed again right
away.
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyB, 0,
SWT.NONE);
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.emptyB, 1,
SWT.NONE);
+ releaseAndCatchDialog(bot, spots, spots.emptyB, SWT.BUTTON1);
+ fire(
+ spots.canvas,
+ SWT.MouseMove,
+ spots.scale,
+ midpoint(spots.emptyB, spots.emptyC),
+ 0,
+ SWT.NONE);
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyC, 0,
SWT.NONE);
+
+ assertAll(
+ () ->
+ assertNull(
+ pendingHop,
+ "releasing over empty canvas must cancel the pending hop
right away"),
+ () -> assertNoHop(workflowMeta, source, target),
+ () -> assertNoFailures(),
+ () -> assertDidNotMove(sourceWas, source),
+ () -> assertCanvasIsIdle(graph));
+ });
+ }
+
+ // ------------------------------------------------------------------ middle
button
+
+ @Test
+ void middleDraggingOntoTheTargetCreatesTheHop() {
+ onCanvas(
+ (bot, graph, workflowMeta, spots) -> {
+ ActionMeta source = workflowMeta.findAction(SOURCE_ACTION);
+ ActionMeta target = workflowMeta.findAction(TARGET_ACTION);
+
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 2,
SWT.NONE);
+ fire(
+ spots.canvas,
+ SWT.MouseMove,
+ spots.scale,
+ midpoint(spots.source, spots.target),
+ 0,
+ SWT.BUTTON2);
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.target, 0,
SWT.BUTTON2);
+ String popup = releaseAndCatchDialog(bot, spots, spots.target, 2,
SWT.BUTTON2);
+
+ assertAll(
+ () -> assertHopExists(workflowMeta, source, target),
+ () -> assertNoDialog(popup),
+ () -> assertNoFailures(),
+ () -> assertCanvasIsIdle(graph));
+ });
+ }
+
+ @Test
+ void middleDraggingOntoEmptyCanvasCancelsTheHop() {
+ onCanvas(
+ (bot, graph, workflowMeta, spots) -> {
+ ActionMeta source = workflowMeta.findAction(SOURCE_ACTION);
+ ActionMeta target = workflowMeta.findAction(TARGET_ACTION);
+ Point sourceWas = new Point(source.getLocation());
+
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 2,
SWT.NONE);
+ fire(
+ spots.canvas,
+ SWT.MouseMove,
+ spots.scale,
+ midpoint(spots.source, spots.emptyA),
+ 0,
+ SWT.BUTTON2);
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyA, 0,
SWT.BUTTON2);
+ fire(spots.canvas, SWT.MouseUp, spots.scale, spots.emptyA, 2,
SWT.BUTTON2);
+ Object pendingHop = privateField(graph, "startHopAction");
+
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyB, 0,
SWT.NONE);
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyC, 0,
SWT.NONE);
+
+ assertAll(
+ () ->
+ assertNull(
+ pendingHop,
+ "releasing over empty canvas must cancel the pending hop
right away"),
+ () -> assertNoHop(workflowMeta, source, target),
+ () -> assertNoFailures(),
+ () -> assertDidNotMove(sourceWas, source),
+ () -> assertCanvasIsIdle(graph));
+ });
+ }
+
+ @Test
+ void middleClickingSourceThenClickingTargetCreatesTheHop() {
+ onCanvas(
+ (bot, graph, workflowMeta, spots) -> {
+ ActionMeta source = workflowMeta.findAction(SOURCE_ACTION);
+ ActionMeta target = workflowMeta.findAction(TARGET_ACTION);
+
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 2,
SWT.NONE);
+ fire(spots.canvas, SWT.MouseUp, spots.scale, spots.source, 2,
SWT.BUTTON2);
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.target, 0,
SWT.NONE);
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.target, 1,
SWT.NONE);
+ String popup = releaseAndCatchDialog(bot, spots, spots.target,
SWT.BUTTON1);
+
+ assertAll(
+ () -> assertHopExists(workflowMeta, source, target),
+ () -> assertNoDialog(popup),
+ () -> assertNoFailures(),
+ () -> assertCanvasIsIdle(graph));
+ });
+ }
+
+ @Test
+ void middleClickingSourceThenClickingEmptyCanvasCancelsTheHop() {
+ onCanvas(
+ (bot, graph, workflowMeta, spots) -> {
+ ActionMeta source = workflowMeta.findAction(SOURCE_ACTION);
+ ActionMeta target = workflowMeta.findAction(TARGET_ACTION);
+ Point sourceWas = new Point(source.getLocation());
+
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 2,
SWT.NONE);
+ fire(spots.canvas, SWT.MouseUp, spots.scale, spots.source, 2,
SWT.BUTTON2);
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyA, 0,
SWT.NONE);
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.emptyA, 1,
SWT.NONE);
+ String popup = releaseAndCatchDialog(bot, spots, spots.emptyA,
SWT.BUTTON1);
+
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyB, 0,
SWT.NONE);
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyC, 0,
SWT.NONE);
+
+ assertAll(
+ () -> assertNoHop(workflowMeta, source, target),
+ () -> assertNoDialog(popup),
+ () -> assertNoFailures(),
+ () -> assertDidNotMove(sourceWas, source),
+ () -> assertCanvasIsIdle(graph));
+ });
+ }
+
+ // ------------------------------------------------------------------ escape
and right click
+
+ @Test
+ void escapeCancelsThePendingHop() {
+ onCanvas(
+ (bot, graph, workflowMeta, spots) -> {
+ ActionMeta source = workflowMeta.findAction(SOURCE_ACTION);
+ ActionMeta target = workflowMeta.findAction(TARGET_ACTION);
+ Point sourceWas = new Point(source.getLocation());
+
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 1,
SWT.SHIFT);
+ fire(spots.canvas, SWT.MouseUp, spots.scale, spots.source, 1,
SWT.SHIFT | SWT.BUTTON1);
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyA, 0,
SWT.NONE);
+ assertNotNull(privateField(graph, "startHopAction"), "the hop should
be pending by now");
+
+ fireKey(spots.canvas, SWT.ESC);
+ Object pendingHop = privateField(graph, "startHopAction");
+
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyB, 0,
SWT.NONE);
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyC, 0,
SWT.NONE);
+
+ assertAll(
+ () -> assertNull(pendingHop, "escape must cancel the pending
hop"),
+ () -> assertNoHop(workflowMeta, source, target),
+ () -> assertNoFailures(),
+ () -> assertDidNotMove(sourceWas, source),
+ () -> assertCanvasIsIdle(graph));
+ });
+ }
+
+ @Test
+ void rightClickingEmptyCanvasCancelsThePendingHop() {
+ onCanvas(
+ (bot, graph, workflowMeta, spots) -> {
+ ActionMeta source = workflowMeta.findAction(SOURCE_ACTION);
+ ActionMeta target = workflowMeta.findAction(TARGET_ACTION);
+ Point sourceWas = new Point(source.getLocation());
+
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 1,
SWT.SHIFT);
+ fire(spots.canvas, SWT.MouseUp, spots.scale, spots.source, 1,
SWT.SHIFT | SWT.BUTTON1);
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyA, 0,
SWT.NONE);
+
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.emptyA, 3,
SWT.NONE);
+ String popup = releaseAndCatchDialog(bot, spots, spots.emptyA, 3,
SWT.BUTTON3);
+
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyB, 0,
SWT.NONE);
+ fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyC, 0,
SWT.NONE);
+
+ assertAll(
+ () -> assertNoHop(workflowMeta, source, target),
+ () -> assertNoDialog(popup),
+ () -> assertNoFailures(),
+ () -> assertDidNotMove(sourceWas, source),
+ () -> assertCanvasIsIdle(graph));
+ });
+ }
+
+ // ------------------------------------------------------------------
gesture building blocks
+
+ /**
+ * Clicks the action and runs the "Create hop" item of the context dialog
that pops up. Only the
+ * picking of the item is short-circuited - the click that opens the dialog
is a real one, so the
+ * state it leaves behind is real too.
+ */
+ private void clickActionAndPickCreateHop(
+ HopGuiWorkflowGraph graph, Spots spots, ActionMeta action) {
+ // Defer the context dialog and cancel it, the way a following click does,
so the test does
+ // not have to drive the dialog itself.
+ onUi(() -> PropsUi.getInstance().setUseDoubleClickOnCanvas(true));
+ fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 1, SWT.NONE);
+ fire(spots.canvas, SWT.MouseUp, spots.scale, spots.source, 1, SWT.BUTTON1);
+ cancelPendingContextDialog(graph);
+ onUi(() -> PropsUi.getInstance().setUseDoubleClickOnCanvas(false));
+
+ onUi(
+ () ->
+ graph.newHopCandidate(
+ new HopGuiWorkflowActionContext(
+ graph.getWorkflowMeta(), action, graph, new
Point(spots.source))));
+ }
+
+ private void cancelPendingContextDialog(HopGuiWorkflowGraph graph) {
+ Object pending = privateField(graph, "pendingShowContextDialogRunnable");
+ if (pending instanceof Runnable runnable) {
+ onUi(() -> display.timerExec(-1, runnable));
+ }
+ }
+
+ /**
+ * Releases the mouse button and reports the title of the dialog that
opened, or null when none
+ * did. Any dialog is closed again so the canvas event loop is handed back.
+ */
+ private String releaseAndCatchDialog(SWTBot bot, Spots spots, Point at, int
stateMask) {
+ return releaseAndCatchDialog(bot, spots, at, 1, stateMask);
+ }
+
+ private String releaseAndCatchDialog(
+ SWTBot bot, Spots spots, Point at, int button, int stateMask) {
+ Set<Shell> before = openShells();
+ fireAsync(spots.canvas, SWT.MouseUp, spots.scale, at, button, stateMask);
+ Shell popup = awaitNewShell(bot, before);
+ String title = titleOf(popup);
+ closeShell(bot, popup);
+ return title;
+ }
+
+ // ------------------------------------------------------------------
assertions
+
+ private void assertHopExists(WorkflowMeta meta, ActionMeta from, ActionMeta
to) {
+ assertNotNull(
+ meta.findWorkflowHop(from, to),
+ "the gesture should have created the hop " + from + " → " + to);
+ }
+
+ private void assertNoHop(WorkflowMeta meta, ActionMeta from, ActionMeta to) {
+ assertNull(meta.findWorkflowHop(from, to), "the abandoned gesture must not
create a hop");
+ }
+
+ private void assertNoDialog(String dialogTitle) {
+ assertNull(dialogTitle, "no dialog may open here, but one did");
+ }
+
+ private void assertNoFailures() {
+ assertTrue(swallowed.isEmpty(), "the canvas must not throw, but got " +
swallowed);
+ }
+
+ private static void assertDidNotMove(Point was, ActionMeta action) {
+ assertEquals(
+ was.toString(), action.getLocation().toString(), "the action followed
the mouse pointer");
+ }
+
+ /** Everything the graph remembers about a gesture has to be back to its
initial value. */
+ private static void assertCanvasIsIdle(HopGuiWorkflowGraph graph) {
+ Map<String, Object> idle = new LinkedHashMap<>();
+ idle.put("startHopAction", null);
+ idle.put("endHopAction", null);
+ idle.put("endHopLocation", null);
+ idle.put("hopCandidate", null);
+ idle.put("currentAction", null);
+ idle.put("selectedAction", null);
+ idle.put("selectedActions", null);
+ idle.put("selectionRegion", null);
+ idle.put("splitHop", false);
+ idle.put("dragSelection", false);
+ idle.put("avoidContextDialog", false);
+ idle.put("ignoreNextClick", false);
+ idle.put("lastButton", 0);
+ assertGraphState(graph, idle);
+ }
+
+ // ------------------------------------------------------------------ scene
+
+ /** What a test needs to aim at: the canvas, its scale and the interesting
graph coordinates. */
+ private record Spots(
+ Canvas canvas,
+ double scale,
+ Point source,
+ Point target,
+ Point emptyA,
+ Point emptyB,
+ Point emptyC) {}
+
+ @FunctionalInterface
+ private interface CanvasTest {
+ void run(SWTBot bot, HopGuiWorkflowGraph graph, WorkflowMeta workflowMeta,
Spots spots);
+ }
+
+ private void onCanvas(CanvasTest test) {
+ AtomicReference<HopGuiWorkflowGraph> graphRef = new AtomicReference<>();
+ AtomicReference<WorkflowMeta> metaRef = new AtomicReference<>();
+
+ withScene(
+ shell -> {
+ shell.setSize(1000, 700);
+ // The workflow graph gives itself GridData, so its parent lays out
with a GridLayout.
+ shell.setLayout(new GridLayout(1, false));
+ // Keep the single click on the canvas synchronous and deterministic.
+ PropsUi.getInstance().setUseDoubleClickOnCanvas(false);
+
+ WorkflowMeta workflowMeta = buildWorkflow();
+ metaRef.set(workflowMeta);
+ graphRef.set(
+ new HopGuiWorkflowGraph(
+ shell,
+ hopGui(),
+ new ExplorerPerspective(),
+ workflowMeta,
+ new HopWorkflowFileType<>()));
+ attachKeyboardShortcuts(shell);
+ },
+ bot -> {
+ HopGuiWorkflowGraph graph = graphRef.get();
+ WorkflowMeta workflowMeta = metaRef.get();
+ test.run(bot, graph, workflowMeta, aim(bot, graph, workflowMeta));
+ });
+ }
+
+ /** Locates both icons and three spots of empty canvas, clear of the icons
and of the minimap. */
+ private Spots aim(SWTBot bot, HopGuiWorkflowGraph graph, WorkflowMeta
workflowMeta) {
+ Canvas canvas = onUi(graph::getCanvas);
+ double scale = canvasToGraphScale(graph);
+ AreaLookup lookup = graph::getVisibleAreaOwner;
+
+ Point source =
+ awaitIcon(
+ bot,
+ lookup,
+ AreaOwner.AreaType.ACTION_ICON,
+ workflowMeta.findAction(SOURCE_ACTION),
+ SOURCE_LOCATION);
+ Point target =
+ awaitIcon(
+ bot,
+ lookup,
+ AreaOwner.AreaType.ACTION_ICON,
+ workflowMeta.findAction(TARGET_ACTION),
+ TARGET_LOCATION);
+
+ Rectangle bounds = onUi(canvas::getBounds);
+ Point visible = onUi(() -> graph.screen2real(bounds.width, bounds.height));
+ Point emptyA = new Point((int) (visible.x * 0.50), (int) (visible.y *
0.45));
+ Point emptyB = new Point((int) (visible.x * 0.30), (int) (visible.y *
0.60));
+ Point emptyC = new Point((int) (visible.x * 0.20), (int) (visible.y *
0.72));
+ assertEmptyCanvas(lookup, emptyA, emptyB, emptyC);
+
+ return new Spots(canvas, scale, source, target, emptyA, emptyB, emptyC);
+ }
+
+ /** Two actions, far enough apart to aim at either icon, with no hop between
them yet. */
+ private static WorkflowMeta buildWorkflow() {
+ WorkflowMeta workflowMeta = new WorkflowMeta();
+ workflowMeta.setName("hop-creation");
+ workflowMeta.addAction(action(SOURCE_ACTION, SOURCE_LOCATION));
+ workflowMeta.addAction(action(TARGET_ACTION, TARGET_LOCATION));
+ return workflowMeta;
+ }
+
+ private static ActionMeta action(String name, Point location) {
+ ActionMeta actionMeta = new ActionMeta(new TestAction(name));
+ actionMeta.setLocation(location.x, location.y);
+ return actionMeta;
+ }
+
+ /** The smallest possible action: the canvas only needs a name, a location
and an icon slot. */
+ private static class TestAction extends ActionBase {
+ TestAction(String name) {
+ super(name, "");
+ }
+
+ @Override
+ public Result execute(Result prevResult, int nr) {
+ return prevResult;
+ }
+
+ @Override
+ public Object clone() {
+ return new TestAction(getName());
+ }
+ }
+}
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
index f957c93e87..8eee4e91ed 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
@@ -1020,12 +1020,7 @@ public class HopGuiPipelineGraph extends
HopGuiAbstractGraph
// go away.
//
if (startHopTransform != null) {
- canvas.setData("mode", "null");
- canvas.setData(START_HOP_NODE, null);
- startHopTransform = null;
- endHopLocation = null;
- candidate = null;
- lastClick = null;
+ cancelHopCandidate();
avoidContextDialog = true;
redraw();
return;
@@ -1171,6 +1166,17 @@ public class HopGuiPipelineGraph extends
HopGuiAbstractGraph
}
}
+ // A hop candidate released anywhere but on a transform is abandoned.
Cancel it here: nothing
+ // below completes it, so without this the canvas keeps drawing a hop
nobody is drawing any
+ // more and the next click still works on a half-finished gesture.
+ //
+ if (startHopTransform != null && pipelineMeta.getTransform(real.x, real.y,
iconSize) == null) {
+ cancelHopCandidate();
+ lastButton = 0;
+ redraw();
+ return;
+ }
+
// Special cases...
//
if (areaOwner != null && areaOwner.getAreaType() != null) {
@@ -1399,6 +1405,7 @@ public class HopGuiPipelineGraph extends
HopGuiAbstractGraph
if (avoidContextDialog) {
avoidContextDialog = false;
selectionRegion = null;
+ lastButton = 0;
return;
}
@@ -2600,8 +2607,27 @@ public class HopGuiPipelineGraph extends
HopGuiAbstractGraph
return canvas != null && !canvas.isDisposed() && canvas.setFocus();
}
+ /**
+ * Drop the hop that was being drawn. Every field the gesture touched goes
back to its initial
+ * value, including the transform the mouse went down on: a stale one makes
the next mouse-up look
+ * like a click on that transform.
+ */
+ private void cancelHopCandidate() {
+ canvas.setData("mode", "null");
+ canvas.setData(START_HOP_NODE, null);
+ startHopTransform = null;
+ endHopTransform = null;
+ endHopLocation = null;
+ candidate = null;
+ candidateHopType = null;
+ startErrorHopTransform = false;
+ forbiddenTransform = null;
+ currentTransform = null;
+ }
+
public void clearSettings() {
selectedTransform = null;
+ currentTransform = null;
forbiddenTransform = null;
selectedNote = null;
selectedTransforms = null;
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
index bf15ca081b..3b922f0455 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
@@ -723,6 +723,11 @@ public class HopGuiWorkflowGraph extends
HopGuiAbstractGraph
// candidate hop, but we need to ignore this click to not start a
drag operation
if (hopCandidate != null) {
addCandidateAsHop();
+ // The hop completes on mouseDown and clears startHopAction;
without this, the
+ // following mouseUp would look like a plain action click and
open the context dialog.
+ if (startHopAction == null) {
+ avoidContextDialog = true;
+ }
}
} else if (event.button == 2 || (event.button == 1 && shift)) {
// SHIFT CLICK is start of drag to create a new hop
@@ -890,12 +895,8 @@ public class HopGuiWorkflowGraph extends
HopGuiAbstractGraph
// go away.
//
if (startHopAction != null) {
- canvas.setData("mode", "null");
- canvas.setData(START_HOP_NODE, null);
- startHopAction = null;
- hopCandidate = null;
- endHopLocation = null;
- lastClick = null;
+ cancelHopCandidate();
+ avoidContextDialog = true;
redraw();
return;
}
@@ -1033,6 +1034,17 @@ public class HopGuiWorkflowGraph extends
HopGuiAbstractGraph
}
}
+ // A hop candidate released anywhere but on an action is abandoned. Cancel
it here: nothing
+ // below completes it, so without this the canvas keeps drawing a hop
nobody is drawing any
+ // more and the next click still works on a half-finished gesture.
+ //
+ if (startHopAction != null && workflowMeta.getAction(real.x, real.y,
iconSize) == null) {
+ cancelHopCandidate();
+ lastButton = 0;
+ redraw();
+ return;
+ }
+
// Quick new hop option? (drag from one action to another)
//
if (areaOwner != null && areaOwner.getAreaType() != null) {
@@ -1042,6 +1054,7 @@ public class HopGuiWorkflowGraph extends
HopGuiAbstractGraph
// Mouse up while drawing a hop candidate
addCandidateAsHop();
redraw();
+ return;
}
break;
case ACTION_NAME:
@@ -1257,6 +1270,7 @@ public class HopGuiWorkflowGraph extends
HopGuiAbstractGraph
if (avoidContextDialog) {
avoidContextDialog = false;
selectionRegion = null;
+ lastButton = 0;
return;
}
@@ -2176,8 +2190,26 @@ public class HopGuiWorkflowGraph extends
HopGuiAbstractGraph
}
}
+ /**
+ * Drop the hop that was being drawn. Every field the gesture touched goes
back to its initial
+ * value, including the action the mouse went down on: a stale one makes the
next mouse-up look
+ * like a click on that action.
+ */
+ private void cancelHopCandidate() {
+ canvas.setData("mode", "null");
+ canvas.setData(START_HOP_NODE, null);
+ startHopAction = null;
+ endHopAction = null;
+ endHopLocation = null;
+ hopCandidate = null;
+ forbiddenAction = null;
+ currentAction = null;
+ }
+
public void clearSettings() {
selectedAction = null;
+ currentAction = null;
+ endHopLocation = null;
selectedNote = null;
selectedActions = null;
selectedNotes = null;