diff options
Diffstat (limited to 'tests/auto')
| -rw-r--r-- | tests/auto/quick/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | tests/auto/quick/qquicknativedrag/CMakeLists.txt | 49 | ||||
| -rw-r--r-- | tests/auto/quick/qquicknativedrag/data/listViewDelegateDrag.qml | 41 | ||||
| -rw-r--r-- | tests/auto/quick/qquicknativedrag/tst_qquicknativedrag.cpp | 278 |
4 files changed, 369 insertions, 0 deletions
diff --git a/tests/auto/quick/CMakeLists.txt b/tests/auto/quick/CMakeLists.txt index 035f960010..18ef46d300 100644 --- a/tests/auto/quick/CMakeLists.txt +++ b/tests/auto/quick/CMakeLists.txt @@ -43,6 +43,7 @@ if(QT_FEATURE_private_tests) endif() add_subdirectory(qquickdrag) add_subdirectory(qquickdragattached) + add_subdirectory(qquicknativedrag) add_subdirectory(qquickdroparea) add_subdirectory(qquickdynamicpropertyanimation) add_subdirectory(qquickflickable) diff --git a/tests/auto/quick/qquicknativedrag/CMakeLists.txt b/tests/auto/quick/qquicknativedrag/CMakeLists.txt new file mode 100644 index 0000000000..1f1006d02b --- /dev/null +++ b/tests/auto/quick/qquicknativedrag/CMakeLists.txt @@ -0,0 +1,49 @@ +# Copyright (C) 2026 The Qt Company Ltd. +# SPDX-License-Identifier: BSD-3-Clause + +##################################################################### +## tst_qquicknativedrag Test: +##################################################################### + +if(NOT QT_BUILD_STANDALONE_TESTS AND NOT QT_BUILDING_QT) + cmake_minimum_required(VERSION 3.16) + project(tst_qquicknativedrag LANGUAGES CXX) + find_package(Qt6BuildInternals REQUIRED COMPONENTS STANDALONE_TEST) +endif() + +qt_internal_add_test(tst_qquicknativedrag + SOURCES + tst_qquicknativedrag.cpp + LIBRARIES + Qt::CorePrivate + Qt::Gui + Qt::GuiPrivate + Qt::Qml + Qt::QmlPrivate + Qt::Quick + Qt::QuickPrivate + Qt::QuickTestUtilsPrivate + TESTDATA ${test_data} +) + +## Scopes: +##################################################################### + +qt_internal_extend_target(tst_qquicknativedrag CONDITION ANDROID OR IOS + DEFINES + QT_QMLTEST_DATADIR=":/data" +) + +qt_internal_extend_target(tst_qquicknativedrag CONDITION NOT ANDROID AND NOT IOS + DEFINES + QT_QMLTEST_DATADIR="${CMAKE_CURRENT_SOURCE_DIR}/data" +) + +# Drag.dragType: Drag.Automatic goes through QDrag::exec(), which runs a nested event +# loop that only an in-process QPlatformDrag lets synthetic input terminate. The +# offscreen platform provides one, and implements QCursor::setPos(), which +# QSimpleDrag::startDrag() needs to resolve the source window. The tests QSKIP +# elsewhere, so running the binary directly on another platform is still safe. +set_property(TEST tst_qquicknativedrag APPEND PROPERTY ENVIRONMENT + "QT_QPA_PLATFORM=offscreen" +) diff --git a/tests/auto/quick/qquicknativedrag/data/listViewDelegateDrag.qml b/tests/auto/quick/qquicknativedrag/data/listViewDelegateDrag.qml new file mode 100644 index 0000000000..dffbd61c16 --- /dev/null +++ b/tests/auto/quick/qquicknativedrag/data/listViewDelegateDrag.qml @@ -0,0 +1,41 @@ +// Copyright (C) 2026 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only + +import QtQuick + +Rectangle { + width: 200 + height: 200 + color: "black" + + // Receives the drag events that QSimpleDrag feeds back into this process. + DropArea { + objectName: "dropArea" + anchors.fill: parent + } + + ListView { + id: list + objectName: "list" + anchors.fill: parent + model: 100 + cacheBuffer: 0 + boundsBehavior: Flickable.StopAtBounds + clip: true + // The view keeps currentItem alive outside visibleItems, so with the default + // currentIndex of 0 the first delegate would never be released when it scrolls + // out of view -- and the test needs it to be. + currentIndex: -1 + + delegate: Rectangle { + objectName: "delegate" + index + width: list.width + height: 50 + color: "white" + + Drag.dragType: Drag.Automatic + Drag.supportedActions: Qt.MoveAction + Drag.mimeData: { "text/plain": "" + index } + } + } +} diff --git a/tests/auto/quick/qquicknativedrag/tst_qquicknativedrag.cpp b/tests/auto/quick/qquicknativedrag/tst_qquicknativedrag.cpp new file mode 100644 index 0000000000..4b5f1bee1a --- /dev/null +++ b/tests/auto/quick/qquicknativedrag/tst_qquicknativedrag.cpp @@ -0,0 +1,278 @@ +// Copyright (C) 2026 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only + +// Exercises Drag.dragType: Drag.Automatic, i.e. the QDrag::exec() path. tst_qquickdrag +// deliberately covers only Drag.Internal and Drag.None, because exec() runs a nested +// event loop; this test drives that loop instead of avoiding it. + +#include <QtCore/qpointer.h> +#include <QtCore/qregularexpression.h> +#include <QtCore/qtimer.h> +#include <QtGui/qcursor.h> +#include <QtGui/qguiapplication.h> +#include <QtGui/private/qguiapplication_p.h> +#include <QtQml/qqmlcontext.h> +#include <QtQml/qqmlexpression.h> +#include <QtQuick/qquickitem.h> +#include <QtQuick/qquickview.h> +#include <QtQuick/private/qquickdrag_p.h> +#include <QtTest/qsignalspy.h> +#include <QtTest/qtest.h> + +#include <QtQuick/private/qquickitemview_p.h> + +#include <QtQuickTestUtils/private/qmlutils_p.h> +#include <QtQuickTestUtils/private/viewtestutils_p.h> +#include <QtQuickTestUtils/private/visualtestutils_p.h> + +#include <qpa/qplatformdrag.h> +#include <qpa/qplatformintegration.h> +#include <qpa/qwindowsysteminterface.h> + +#include <functional> + +using namespace Qt::StringLiterals; +using namespace std::chrono_literals; + +// QDrag::exec() runs a nested event loop that, on most platforms, only a real user can +// terminate. The offscreen platform installs the in-process QSimpleDrag, which drives +// itself from ordinary mouse and key events, and implements QCursor::setPos(), which +// QSimpleDrag::startDrag() needs to resolve the source window. The CMakeLists.txt for +// this test selects it; "minimal" is not a substitute, as it has no QPlatformCursor. +static bool haveInProcessDrag() +{ + if (QGuiApplication::platformName() != "offscreen"_L1) + return false; + // QBasicDrag, which QSimpleDrag derives from, is a QObject: it installs itself as an + // application event filter to track the mouse. A stub implementation that returns + // Qt::IgnoreAction without running an event loop is not, so this also skips rather + // than fails when built against a Qt version whose offscreen plugin still has one. + return dynamic_cast<QObject *>(QGuiApplicationPrivate::platformIntegration()->drag()); +} + +#define SKIP_IF_NO_IN_PROCESS_DRAG() \ + do { \ + if (!haveInProcessDrag()) \ + QSKIP("Needs the in-process QSimpleDrag; run with -platform offscreen"); \ + } while (false) + +// Input is posted rather than delivered with QTest::mouse*(), which is not usable from +// inside a drag: those go through qt_handleMouseEvent(), which delivers synchronously +// and then calls qApp->processEvents(). That nested processEvents() clears the event +// dispatcher's interrupt flag and drains its wakeup pipe, so if the event ended the +// drag, the QEventLoop::exit() it triggered is swallowed and the drag loop blocks until +// something else happens to wake it. Posting lets the drag's own loop drain the queue. +static void postMouseMove(QWindow *window, const QPoint &local) +{ + QWindowSystemInterface::handleMouseEvent(window, local, window->mapToGlobal(local), + Qt::LeftButton, Qt::NoButton, QEvent::MouseMove); +} + +static void postMouseRelease(QWindow *window, const QPoint &local) +{ + QWindowSystemInterface::handleMouseEvent(window, local, window->mapToGlobal(local), + Qt::NoButton, Qt::LeftButton, + QEvent::MouseButtonRelease); +} + +class tst_QQuickNativeDrag : public QQmlDataTest +{ + Q_OBJECT +public: + tst_QQuickNativeDrag() : QQmlDataTest(QT_QMLTEST_DATADIR) {} + +private slots: + void init() override; + + void completes(); + void delegateDestroyedWhileDragging_data(); + void delegateDestroyedWhileDragging(); + +private: + // Starts a native drag on item, invoking insideLoop from within QDrag::exec()'s + // nested event loop. + static void runDrag(QQuickView *window, QQuickItem *item, QQuickDragAttached *attached, + std::function<void()> insideLoop); + static QQuickDragAttached *dragAttached(QQuickItem *item) + { + return qobject_cast<QQuickDragAttached *>( + qmlAttachedPropertiesObject<QQuickDrag>(item, /*create*/ true)); + } + static void evaluate(QObject *scope, const QString &expression) + { + QQmlExpression expr(qmlContext(scope), scope, expression); + const QVariant result = expr.evaluate(); + Q_UNUSED(result); + QVERIFY2(!expr.hasError(), qPrintable(expr.error().toString())); + } +}; + +void tst_QQuickNativeDrag::init() +{ + QQmlDataTest::init(); + // runDrag() warns when its watchdog has to break into a stuck nested event loop. + QTest::failOnWarning(QRegularExpression(u"^watchdog:"_s)); +} + +void tst_QQuickNativeDrag::runDrag(QQuickView *window, QQuickItem *item, + QQuickDragAttached *attached, + std::function<void()> insideLoop) +{ + const QPoint pressPos = item->mapToScene(QPointF(10, 10)).toPoint(); + + // QSimpleDrag::startDrag() resolves the source window from QCursor::pos() rather + // than from the last mouse event, so this is needed before the drag starts. On + // offscreen it takes effect immediately, with no round trip to a compositor. + QCursor::setPos(window->mapToGlobal(pressPos)); + QCoreApplication::processEvents(); + QCOMPARE(QCursor::pos(), window->mapToGlobal(pressPos)); + + postMouseMove(window, pressPos); + QWindowSystemInterface::handleMouseEvent(window, pressPos, window->mapToGlobal(pressPos), + Qt::LeftButton, Qt::LeftButton, + QEvent::MouseButtonPress); + QCoreApplication::processEvents(); + + // dragStarted() is emitted from QQuickDragAttachedPrivate::startDrag() just before + // QDrag::exec(), so a queued call from it is delivered inside the nested loop -- + // by which time QBasicDrag has installed the event filter that makes posted input + // drive the drag. + if (insideLoop) { + QObject::connect(attached, &QQuickDragAttached::dragStarted, attached, + std::move(insideLoop), Qt::QueuedConnection); + } + + // Fail rather than hang if nothing terminates the loop. + QTimer watchdog; + watchdog.setSingleShot(true); + watchdog.setInterval(5s); + QObject::connect(&watchdog, &QTimer::timeout, &watchdog, [] { + qWarning("watchdog: nested drag event loop did not terminate"); + if (QPlatformDrag *pd = QGuiApplicationPrivate::platformIntegration()->drag()) + pd->cancelDrag(); + }); + watchdog.start(); + + QPointer<QQuickDragAttached> guard(attached); + evaluate(item, u"Drag.active = true"_s); + // setActive() defers startDrag() to the event loop, and exec() then blocks until the + // input posted by insideLoop ends the drag -- so spinning here runs the whole thing. + // The attached object may not survive it, hence the guard. + QTRY_VERIFY(!guard || !guard->isActive()); +} + +// Baseline: a native drag from a delegate starts, is seen by the DropArea, and finishes. +void tst_QQuickNativeDrag::completes() +{ + SKIP_IF_NO_IN_PROCESS_DRAG(); + + QQuickView window; + QVERIFY(QQuickTest::showView(window, testFileUrl(u"listViewDelegateDrag.qml"_s))); + QQuickItemView *view = window.rootObject()->findChild<QQuickItemView *>(); + QVERIFY(view); + QQuickItem *delegate = QQuickVisualTestUtils::findViewDelegateItem(view, 0); + QVERIFY(delegate); + QQuickDragAttached *attached = dragAttached(delegate); + QVERIFY(attached); + const QQuickItem *dropArea = window.rootObject()->findChild<QQuickItem *>(u"dropArea"_s); + QVERIFY(dropArea); + + QSignalSpy startedSpy(attached, SIGNAL(dragStarted())); + QSignalSpy finishedSpy(attached, SIGNAL(dragFinished(Qt::DropAction))); + + const QPoint dropPos = delegate->mapToScene(QPointF(10, 120)).toPoint(); + runDrag(&window, delegate, attached, [&] { + postMouseMove(&window, dropPos); + postMouseRelease(&window, dropPos); + }); + + QCOMPARE(startedSpy.size(), 1); + QTRY_COMPARE(finishedSpy.size(), 1); + QVERIFY(!attached->isActive()); + QVERIFY(!attached->target()); +} + +void tst_QQuickNativeDrag::delegateDestroyedWhileDragging_data() +{ + QTest::addColumn<QString>("destroyExpression"); + // The delegate scrolls out of the visible area, and with cacheBuffer 0 the view + // releases it immediately. This is what QTBUG-124663 does by auto-scrolling. + QTest::newRow("recycled") << u"list.contentY = 50 * 40"_s; + // The model rows backing every delegate go away. Nothing item-view-specific is + // needed to reproduce this -- any delegate model teardown will do. + QTest::newRow("model reset") << u"list.model = 0"_s; +} + +// QTBUG-124663: the delegate that owns the QQuickDragAttached -- and that the QDrag is +// parented to -- is destroyed inside QDrag::exec()'s nested event loop. Must not crash, +// must not leave the drag active, and must not leave the DropArea entered. +void tst_QQuickNativeDrag::delegateDestroyedWhileDragging() +{ + SKIP_IF_NO_IN_PROCESS_DRAG(); + QFETCH(const QString, destroyExpression); + + QQuickView window; + QVERIFY(QQuickTest::showView(window, testFileUrl(u"listViewDelegateDrag.qml"_s))); + QQuickItemView *view = window.rootObject()->findChild<QQuickItemView *>(); + QVERIFY(view); + QQuickItem *delegate = QQuickVisualTestUtils::findViewDelegateItem(view, 0); + QVERIFY(delegate); + QPointer<QQuickItem> delegateGuard(delegate); + QPointer<QQuickDragAttached> attached(dragAttached(delegate)); + QVERIFY(attached); + const QQuickItem *dropArea = window.rootObject()->findChild<QQuickItem *>(u"dropArea"_s); + QVERIFY(dropArea); + + QSignalSpy startedSpy(attached, SIGNAL(dragStarted())); + QSignalSpy finishedSpy(attached, SIGNAL(dragFinished(Qt::DropAction))); + QSignalSpy enteredSpy(dropArea, SIGNAL(entered(QQuickDragEvent*))); + QSignalSpy exitedSpy(dropArea, SIGNAL(exited())); + + QObject *root = window.rootObject(); + runDrag(&window, delegate, attached, [&] { + // We are inside QDrag::exec() here. + evaluate(root, destroyExpression); + // The view releases the delegate on its next polish, and the delegate model then + // queues a deferred delete. Let both happen, still inside the nested loop, which + // is what the stack trace in the bug report shows. Stop as soon as the delegate + // is gone: processEvents() after the drag has ended would clear the event + // dispatcher interrupt that unwinds exec(). + for (int i = 0; i < 10 && !delegateGuard.isNull(); ++i) { + QCoreApplication::processEvents(); + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + } + }); + + QVERIFY(delegateGuard.isNull()); + QCOMPARE(startedSpy.size(), 1); + + // The attached object is a QObject child of the delegate, so it is gone too -- but + // it must have reported the drag as finished on the way out, and the DropArea must + // not be left thinking a drag is still in progress. + QCOMPARE(finishedSpy.size(), 1); + QTRY_COMPARE(exitedSpy.size(), enteredSpy.size()); + + // The reported symptom is that the drag is "internally still active" afterwards. + // Prove the state machine recovered by running a second, uneventful drag. Undo + // whatever this data row did first, so that there is a delegate to drag again. + evaluate(root, u"list.model = 100"_s); + evaluate(root, u"list.contentY = 0"_s); + QQuickItem *next = nullptr; + QTRY_VERIFY((next = QQuickVisualTestUtils::findViewDelegateItem(view, 1))); + QQuickDragAttached *nextAttached = dragAttached(next); + QVERIFY(nextAttached); + QSignalSpy secondFinished(nextAttached, SIGNAL(dragFinished(Qt::DropAction))); + + const QPoint dropPos = next->mapToScene(QPointF(10, 30)).toPoint(); + runDrag(&window, next, nextAttached, [&] { + postMouseMove(&window, dropPos); + postMouseRelease(&window, dropPos); + }); + + QTRY_COMPARE(secondFinished.size(), 1); + QVERIFY(!nextAttached->isActive()); +} + +QTEST_MAIN(tst_QQuickNativeDrag) + +#include "tst_qquicknativedrag.moc" |
