Skip to content

Commit 9704205

Browse files
committed
Tests: Add tests for tree tab dran'n'drop
1 parent ec1d802 commit 9704205

File tree

11 files changed

+236
-67
lines changed

11 files changed

+236
-67
lines changed

.github/workflows/build-linux.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,9 @@ jobs:
151151
- name: Test on X11
152152
working-directory: '${{runner.workspace}}/install/copyq/${{ matrix.cmake_preset }}/bin'
153153
run: '${{github.workspace}}/utils/github/test-linux.sh'
154+
env:
155+
COPYQ_TESTS_SKIP_DRAG_AND_DROP: >-
156+
${{ matrix.with_qt6 && '0' || '1' }}
154157
155158
- name: Update coverage
156159
if: matrix.coverage

appveyor.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ environment:
3838
--no-system-d3d-compiler
3939
--no-opengl-sw
4040
--no-quick
41+
COPYQ_TESTS_SKIP_DRAG_AND_DROP: "1"
4142

4243
# Parameters for default build commands (build_script is used instead).
4344
build: false

plugins/itemsync/tests/itemsynctests.cpp

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ using FilePtr = std::shared_ptr<QFile>;
1818

1919
const char sep[] = " ;; ";
2020

21-
const auto confirmRemoveDialogId = "focus::QPushButton in :QMessageBox";
21+
const auto confirmRemoveDialogId =
22+
"focus::QPushButton'Yes'<qt_msgbox_buttonbox:QDialogButtonBox<:QMessageBox"
23+
"'Do you really want to remove items and associated files\\\\?'";
2224

2325
class TestDir final {
2426
public:

src/scriptable/scriptableproxy.cpp

Lines changed: 137 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,78 @@ QString tabNameEmptyError()
651651
return ScriptableProxy::tr("Tab name cannot be empty!");
652652
}
653653

654+
#ifdef HAS_TESTS
655+
QString objectAddress(QObject *object)
656+
{
657+
if (!object)
658+
return QStringLiteral("<null>");
659+
660+
QString result;
661+
QObject *current = object;
662+
while (current) {
663+
const QString className = current->metaObject()->className();
664+
const QString objectName = current->objectName();
665+
const QString name = className == objectName
666+
? className : QStringLiteral("%1:%2").arg(objectName, className);
667+
// Skip some default widgets.
668+
if ( name != QLatin1String(":QWidget")
669+
&& !name.startsWith(QLatin1String("qt_scrollarea_viewport:QWidget")) )
670+
{
671+
if (!result.isEmpty())
672+
result.append('<');
673+
result.append(name);
674+
const QString text = current->property("text").toString()
675+
.remove('&')
676+
// Remove HTML tags
677+
.remove(QRegularExpression(QStringLiteral("</?[^>]*>")));
678+
if ( !text.isEmpty() )
679+
result.append(QStringLiteral("'%1'").arg(text));
680+
}
681+
current = current->parent();
682+
}
683+
return result;
684+
}
685+
686+
bool matchesProperties(QObject *object, const QStringList &properties)
687+
{
688+
for (auto it = properties.cbegin(); it != properties.cend(); ++it) {
689+
const QString key = it->section('=', 0, 0);
690+
const QString value = it->section('=', 1, 1);
691+
if ( value.isEmpty() ) {
692+
if ( object->objectName() != key && object->metaObject()->className() != key )
693+
return false;
694+
}
695+
696+
const QVariant propValue = object->property(key.toUtf8());
697+
if ( propValue.toString() != value )
698+
return false;
699+
}
700+
701+
return true;
702+
}
703+
704+
QWidget *findWidgetWithProperties(const QString &definition, QWidget *parent)
705+
{
706+
QStringList names = definition.split('<');
707+
return std::accumulate(
708+
names.cbegin(), names.cend(), parent,
709+
[](QWidget *parent, const QString &name) -> QWidget* {
710+
if (parent == nullptr)
711+
return nullptr;
712+
713+
const QStringList props = name.split('|', Qt::SkipEmptyParts);
714+
for (QWidget *child : parent->findChildren<QWidget*>()) {
715+
if (child->isVisible() && matchesProperties(child, props)) {
716+
COPYQ_LOG( QStringLiteral("Found target: %1").arg(objectAddress(child)) );
717+
return child;
718+
}
719+
}
720+
log( QStringLiteral("Failed to find mouse action target: %1").arg(name), LogError );
721+
return nullptr;
722+
});
723+
}
724+
#endif
725+
654726
} // namespace
655727

656728
#ifdef HAS_TESTS
@@ -682,11 +754,11 @@ class KeyClicker final : public QObject {
682754
log( QString("Failed to send key press to target widget")
683755
+ QLatin1String(qApp->applicationState() == Qt::ApplicationActive ? "" : "\nApp is INACTIVE!")
684756
+ "\nExpected: /" + expectedWidgetName.pattern() + "/"
685-
+ "\nActual: " + keyClicksTargetDescription(actual)
686-
+ "\nPopup: " + keyClicksTargetDescription(popup)
687-
+ "\nWidget: " + keyClicksTargetDescription(widget)
688-
+ "\nWindow: " + keyClicksTargetDescription(window)
689-
+ "\nModal: " + keyClicksTargetDescription(modal)
757+
+ "\nActual: " + objectAddress(actual)
758+
+ "\nPopup: " + objectAddress(popup)
759+
+ "\nWidget: " + objectAddress(widget)
760+
+ "\nWindow: " + objectAddress(window)
761+
+ "\nModal: " + objectAddress(modal)
690762
+ "\nTitle: " + currentWindowTitle
691763
, LogError );
692764

@@ -711,7 +783,7 @@ class KeyClicker final : public QObject {
711783
return;
712784
}
713785

714-
auto widgetName = keyClicksTargetDescription(widget);
786+
const QString widgetName = objectAddress(widget);
715787
if ( !expectedWidgetName.pattern().isEmpty()
716788
&& !expectedWidgetName.match(widgetName).hasMatch() )
717789
{
@@ -730,7 +802,7 @@ class KeyClicker final : public QObject {
730802
if ( qobject_cast<QCheckBox*>(widget) )
731803
waitFor(100);
732804

733-
COPYQ_LOG( QString("Sending keys \"%1\" to %2.")
805+
COPYQ_LOG( QString("Sending event \"%1\" to %2.")
734806
.arg(keys, widgetName) );
735807

736808
const auto popupMessage = QString::fromLatin1("%1 (%2)")
@@ -740,13 +812,55 @@ class KeyClicker final : public QObject {
740812
notification->setIcon(IconKeyboard);
741813
notification->setInterval(2000);
742814

743-
if ( keys.startsWith(":") ) {
744-
const auto text = keys.mid(1);
815+
static const auto keyClicksPrefix = QLatin1String(":");
816+
static const auto mousePrefix = QLatin1String("mouse|");
817+
if ( keys.startsWith(keyClicksPrefix) ) {
818+
const auto text = keys.mid(keyClicksPrefix.size());
745819

746820
QTest::keyClicks(widget, text, Qt::NoModifier, 0);
747821

748822
// Increment key clicks sequence number after typing all the text.
749823
m_succeeded = true;
824+
} else if ( keys.startsWith(mousePrefix) ) {
825+
const QString action = keys.section('|', 1, 1);
826+
const QString properties = keys.section('|', 2);
827+
static const auto mousePress = QStringLiteral("PRESS");
828+
static const auto mouseRelease = QStringLiteral("RELEASE");
829+
static const auto mouseClick = QStringLiteral("CLICK");
830+
static const auto mouseMove = QStringLiteral("MOVE");
831+
static const QStringList validActions = {
832+
mousePress, mouseRelease, mouseClick, mouseMove};
833+
if ( properties.isEmpty() || !validActions.contains(action) ) {
834+
log( QStringLiteral("Failed to match mouse action: %1").arg(keys), LogError );
835+
log("Mouse action format must be: " "mouse|{PRESS|RELEASE}|OBJECT_PATH");
836+
m_failed = true;
837+
return;
838+
}
839+
QPointer<QWidget> source = findWidgetWithProperties(properties, m_wnd);
840+
if (!source) {
841+
m_failed = true;
842+
return;
843+
}
844+
// Don't stop when modal window is open.
845+
runAfterInterval(delay, [=](){
846+
if (!source) {
847+
log("Target widget was destroyed", LogError);
848+
return;
849+
}
850+
if (!source->isVisible()) {
851+
log("Target widget is no longer visible", LogError);
852+
return;
853+
}
854+
if (action == mousePress)
855+
QTest::mousePress(source, Qt::LeftButton);
856+
else if (action == mouseRelease)
857+
QTest::mouseRelease(source, Qt::LeftButton);
858+
else if (action == mouseClick)
859+
QTest::mouseClick(source, Qt::LeftButton);
860+
else if (action == mouseMove)
861+
QTest::mouseMove(source);
862+
});
863+
m_succeeded = true;
750864
} else {
751865
const QKeySequence shortcut(keys, QKeySequence::PortableText);
752866

@@ -766,7 +880,7 @@ class KeyClicker final : public QObject {
766880
0 );
767881
}
768882

769-
COPYQ_LOG( QString("Key \"%1\" sent to %2.")
883+
COPYQ_LOG( QString("Event \"%1\" sent to %2.")
770884
.arg(keys, widgetName) );
771885
}
772886

@@ -776,49 +890,21 @@ class KeyClicker final : public QObject {
776890
m_failed = false;
777891

778892
// Don't stop when modal window is open.
779-
auto t = new QTimer(m_wnd);
780-
t->setSingleShot(true);
781-
QObject::connect( t, &QTimer::timeout, this, [=]() {
782-
keyClicks(expectedWidgetName, keys, delay, retry);
783-
t->deleteLater();
784-
});
785-
t->start(delay);
893+
runAfterInterval(delay, [=](){ keyClicks(expectedWidgetName, keys, delay, retry); });
786894
}
787895

788896
bool succeeded() const { return m_succeeded; }
789897
bool failed() const { return m_failed; }
790898

791899
private:
792-
static QString keyClicksTargetDescription(QWidget *widget)
900+
template <typename Callable>
901+
void runAfterInterval(int delay, Callable func)
793902
{
794-
if (widget == nullptr)
795-
return "None";
796-
797-
const auto className = widget->metaObject()->className();
798-
799-
auto widgetName = QString::fromLatin1("%1:%2")
800-
.arg(widget->objectName(), className);
801-
802-
const auto window = widget->window();
803-
if (window && widget != window) {
804-
widgetName.append(
805-
QString::fromLatin1(" in %1:%2")
806-
.arg(window->objectName(), window->metaObject()->className())
807-
);
808-
}
809-
810-
auto parent = widget->parentWidget();
811-
while (parent) {
812-
if ( parent != window && !parent->objectName().startsWith("qt_") ) {
813-
widgetName.append(
814-
QString::fromLatin1(" in %1:%2")
815-
.arg(parent->objectName(), parent->metaObject()->className())
816-
);
817-
}
818-
parent = parent->parentWidget();
819-
}
820-
821-
return widgetName;
903+
auto t = new QTimer(m_wnd);
904+
t->setSingleShot(true);
905+
QObject::connect(t, &QTimer::timeout, m_wnd, func);
906+
QObject::connect(t, &QTimer::timeout, t, &QTimer::deleteLater);
907+
t->start(delay);
822908
}
823909

824910
QWidget *keyClicksTarget()
@@ -2015,8 +2101,11 @@ void ScriptableProxy::sendKeys(const QString &expectedWidgetName, const QString
20152101
{
20162102
INVOKE2(sendKeys, (expectedWidgetName, keys, delay));
20172103
Q_ASSERT( keyClicker()->succeeded() || keyClicker()->failed() );
2018-
keyClicker()->sendKeyClicks(
2019-
QRegularExpression(expectedWidgetName), keys, delay, 10);
2104+
const QRegularExpression re = QRegularExpression(
2105+
QString(expectedWidgetName)
2106+
.replace(QLatin1String("<"), QLatin1String(".*<.*"))
2107+
);
2108+
keyClicker()->sendKeyClicks(re, keys, delay, 10);
20202109
}
20212110

20222111
bool ScriptableProxy::sendKeysSucceeded()

src/tests/test_utils.h

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,12 @@ constexpr auto customMenuId = "focus:CustomMenu";
2828
constexpr auto editorId = "focus::ItemEditorWidget";
2929
constexpr auto tabDialogLineEditId = "focus:lineEditTabName";
3030
constexpr auto commandDialogId = "focus:CommandDialog";
31-
constexpr auto commandDialogSaveButtonId = "focus::QPushButton in :QMessageBox";
31+
constexpr auto commandDialogSaveButtonId =
32+
"focus::QPushButton'Save'<:QMessageBox'Command dialog has unsaved changes.'";
3233
constexpr auto commandDialogListId = "focus:listWidgetItems";
3334
constexpr auto configurationDialogId = "focus:ConfigurationManager";
34-
constexpr auto shortcutButtonId = "focus::QToolButton in CommandDialog";
35-
constexpr auto shortcutDialogId = "focus::QKeySequenceEdit in ShortcutDialog";
35+
constexpr auto shortcutButtonId = "focus::QToolButton<CommandDialog";
36+
constexpr auto shortcutDialogId = "focus::QKeySequenceEdit<ShortcutDialog";
3637
constexpr auto actionDialogId = "focus:ActionDialog";
3738
constexpr auto aboutDialogId = "focus:AboutDialog";
3839
constexpr auto logDialogId = "focus:LogDialog";
@@ -41,8 +42,11 @@ constexpr auto actionHandlerFilterId = "focus:filterLineEdit";
4142
constexpr auto actionHandlerTableId = "focus:tableView";
4243
constexpr auto clipboardDialogId = "focus:ClipboardDialog";
4344
constexpr auto clipboardDialogFormatListId = "focus:listWidgetFormats";
44-
constexpr auto confirmExitDialogId = "focus::QPushButton in :QMessageBox";
45-
constexpr auto itemPreviewId = "focus:in dockWidgetItemPreviewContents";
45+
constexpr auto confirmExitDialogId =
46+
"focus::QPushButton'Yes'<:QMessageBox'Do you want to exit CopyQ\\\\?'";
47+
constexpr auto runningCommandsExitDialogId =
48+
"focus::QPushButton'Exit Anyway'<:QMessageBox'Cancel active commands and exit\\\\?'";
49+
constexpr auto itemPreviewId = "focus:<dockWidgetItemPreviewContents";
4650

4751
#define NO_ERRORS(ERRORS_OR_EMPTY) !m_test->writeOutErrors(ERRORS_OR_EMPTY)
4852

src/tests/tests.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,9 @@ private slots:
330330

331331
void expireTabs();
332332

333+
void dragNDropTreeTab();
334+
void dragNDropTreeTabNested();
335+
333336
private:
334337
void navigationTestInit();
335338
void navigationTestDownUp(const QString &down, const QString &up);

src/tests/tests_dialogs.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,6 @@ void Tests::exitStopCommands()
277277
RUN("config" << "confirm_exit" << "false", "false\n");
278278
RUN("action" << "copyq sleep 999999", "");
279279
RUN("keys" << clipboardBrowserId << "CTRL+Q", "");
280-
RUN("keys" << confirmExitDialogId << "ENTER", "");
280+
RUN("keys" << runningCommandsExitDialogId << "ENTER", "");
281281
TEST( m_test->waitForServerToStop() );
282282
}

src/tests/tests_drag_n_drop.cpp

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
#include "test_utils.h"
2+
#include "tests.h"
3+
4+
#include "common/sleeptimer.h"
5+
6+
#include <QStyleHints>
7+
8+
// Simulate move event to the target. A single move event does not seem to be
9+
// enough to update the drag target.
10+
#define MOVE_EVENT_COUNT 4
11+
#define MOVE_TO(TARGET) do { \
12+
for (int i = 0; i < MOVE_EVENT_COUNT; ++i) { \
13+
RUN("keys" << "mouse|MOVE|" TARGET, ""); \
14+
waitFor(dragDelay() / MOVE_EVENT_COUNT); \
15+
} \
16+
} while(false)
17+
#define DROP_TO(TARGET) do { \
18+
MOVE_TO(TARGET); \
19+
RUN("keys" << "mouse|RELEASE|" TARGET, ""); \
20+
} while(false)
21+
#define DRAG_FROM_TO(SOURCE, TARGET) do { \
22+
RUN("keys" << "mouse|PRESS|" SOURCE, ""); \
23+
waitFor(dragDelay()); \
24+
DROP_TO(TARGET); \
25+
} while(false)
26+
27+
#define DRAG_TAB(SOURCE, TARGET) \
28+
DRAG_FROM_TO("tab_tree_item|text=" SOURCE, "tab_tree_item|text=" TARGET)
29+
30+
namespace {
31+
32+
int dragDelay()
33+
{
34+
static const int delay = qMax(300, qApp->styleHints()->startDragTime() * 2);
35+
return delay;
36+
}
37+
38+
} // namespace
39+
40+
41+
void Tests::dragNDropTreeTab()
42+
{
43+
SKIP_ON_ENV("COPYQ_TESTS_SKIP_DRAG_AND_DROP");
44+
45+
RUN("config" << "tab_tree" << "true", "true\n");
46+
RUN("config('tabs', ['TAB1','TAB2'])", "TAB1\nTAB2\n");
47+
WAIT_ON_OUTPUT("tab", "TAB1\nTAB2\nCLIPBOARD\n");
48+
49+
DRAG_TAB("TAB2", "TAB1");
50+
RUN("keys" << "mouse|RELEASE|tab_tree_item|text=TAB1", "");
51+
52+
WAIT_ON_OUTPUT("tab", "TAB1\nTAB1/TAB2\nCLIPBOARD\n");
53+
}
54+
55+
void Tests::dragNDropTreeTabNested()
56+
{
57+
SKIP_ON_ENV("COPYQ_TESTS_SKIP_DRAG_AND_DROP");
58+
59+
RUN("config" << "tab_tree" << "true", "true\n");
60+
RUN("config('tabs', ['a/b/c/d','a/b/c'])", "a/b/c/d\na/b/c\n");
61+
WAIT_ON_OUTPUT("tab", "a/b/c/d\na/b/c\nCLIPBOARD\n");
62+
63+
DRAG_TAB("c", "a");
64+
65+
WAIT_ON_OUTPUT("tab", "a/c\na/c/d\nCLIPBOARD\n");
66+
}

0 commit comments

Comments
 (0)