File: models.cpp

package info (click to toggle)
syncthingtray 1.7.5-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 6,804 kB
  • sloc: cpp: 31,085; xml: 1,694; java: 570; sh: 81; javascript: 53; makefile: 25
file content (444 lines) | stat: -rw-r--r-- 22,739 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
#include "../syncthingdevicemodel.h"
#include "../syncthingdirectorymodel.h"
#include "../syncthingfilemodel.h"

#include <syncthingconnector/syncthingconnection.h>

#include <QtTest/QtTest>

#include <QAction>
#include <QDebug>
#include <QEventLoop>
#include <QLocale>
#include <QStringBuilder>
#include <QTimer>

#include <qtutilities/misc/compat.h>

#include <limits>

class ModelTests : public QObject {
    Q_OBJECT

private Q_SLOTS:
    void initTestCase();
    void cleanupTestCase();

    void testDirectoryModel();
    void testDevicesModel();
    void testFileModel();

private:
    QTimer m_timeout;
    QEventLoop m_loop;
    Data::SyncthingConnection m_connection;
};

void ModelTests::initTestCase()
{
    // ensure all text is English
    QLocale::setDefault(QLocale(QLocale::English));

    // setup timeout
    m_timeout.setSingleShot(true);
    m_timeout.setInterval(5000);
    m_timeout.start();
    connect(&m_timeout, &QTimer::timeout, this, [this] {
        m_loop.quit();
        QFAIL("Timeout exceeded");
    });

    // setup error handling
    connect(&m_connection, &Data::SyncthingConnection::error, this,
        [this](const QString &errorMessage, Data::SyncthingErrorCategory, int, const QNetworkRequest &, const QByteArray &) {
            qDebug() << "connection error: " << errorMessage;
            if (errorMessage.contains(QStringLiteral("ignore pattern"))) {
                return; // ignore expected errors
            }
            m_loop.quit();
            QFAIL("Unexpected connection error occurred");
        });

    // request config and status and wait until available
    const auto applyConfig = [this] {
        if (!m_connection.rawConfig().isEmpty() && !m_connection.myId().isEmpty()) {
            m_connection.applyRawConfig();
            m_loop.quit();
        }
    };
    connect(&m_connection, &Data::SyncthingConnection::newConfig, this, applyConfig);
    connect(&m_connection, &Data::SyncthingConnection::myIdChanged, this, applyConfig);
    m_connection.requestConfigAndStatus();
    m_loop.exec();
    m_timeout.stop();
}

void ModelTests::cleanupTestCase()
{
}

void ModelTests::testDirectoryModel()
{
    const auto model = Data::SyncthingDirectoryModel(m_connection);
    QCOMPARE(model.rowCount(QModelIndex()), 3);
    QCOMPARE(model.index(0, 0).data(), QStringLiteral("A folder"));
    QCOMPARE(model.index(1, 0).data(), QStringLiteral("Yet another folder"));
    QCOMPARE(model.index(2, 0).data(), QStringLiteral("A folder which is not shared"));
    const auto folder1Idx = model.index(0, 0);
    QCOMPARE(model.rowCount(folder1Idx), 11);
    QCOMPARE(model.index(0, 0, folder1Idx).data(), QStringLiteral("ID"));
    QCOMPARE(model.index(0, 1, folder1Idx).data(), QStringLiteral("GXWxf-3zgnU"));
    QCOMPARE(model.index(1, 0, folder1Idx).data(), QStringLiteral("Path"));
    QCOMPARE(model.index(1, 1, folder1Idx).data(), QStringLiteral("this/path/is/not/supposed/to/exist"));
    const auto folder2Idx = model.index(1, 0);
    QCOMPARE(model.rowCount(folder2Idx), 11);
    QCOMPARE(model.index(0, 1, folder2Idx).data(), QStringLiteral("zX8xfl3ygn-"));
    QCOMPARE(model.index(1, 1, folder2Idx).data(), QStringLiteral("..."));
    const auto folder3Idx = model.index(2, 0);
    QCOMPARE(model.rowCount(folder2Idx), 11);
    QCOMPARE(model.index(0, 1, folder3Idx).data(), QStringLiteral("forever-alone"));
    QCOMPARE(model.index(1, 1, folder3Idx).data(), QStringLiteral("..."));
}

void ModelTests::testDevicesModel()
{
    const auto model = Data::SyncthingDeviceModel(m_connection);
    QCOMPARE(model.rowCount(QModelIndex()), 2);
    QCOMPARE(model.index(0, 0).data(), QStringLiteral("Myself"));
    QCOMPARE(model.index(0, 1).data(), QStringLiteral("This Device"));
    QCOMPARE(model.index(1, 0).data(), QStringLiteral("Other instance"));
    QCOMPARE(model.index(1, 1).data(), QStringLiteral("Unknown"));
    const auto dev1Idx = model.index(0, 0);
    QCOMPARE(model.rowCount(dev1Idx), 6);
    QCOMPARE(model.index(0, 0, dev1Idx).data(), QStringLiteral("ID"));
    QCOMPARE(model.index(0, 1, dev1Idx).data(), QStringLiteral("P56IOI7-MZJNU2Y-IQGDREY-DM2MGTI-MGL3BXN-PQ6W5BM-TBBZ4TJ-XZWICQ2"));
    QCOMPARE(model.index(1, 0, dev1Idx).data(), QStringLiteral("Out of Sync items"));
    QCOMPARE(model.index(1, 1, dev1Idx).data(), QStringLiteral("none"));
    QCOMPARE(model.index(2, 0, dev1Idx).data(), QStringLiteral("Address"));
    QCOMPARE(model.index(2, 1, dev1Idx).data(), QStringLiteral("dynamic, tcp://192.168.1.2:22000"));
    QCOMPARE(model.index(5, 0, dev1Idx).data(), QStringLiteral("Introducer"));
    QCOMPARE(model.index(5, 1, dev1Idx).data(), QStringLiteral("no"));
    const auto dev2Idx = model.index(1, 0);
    QCOMPARE(model.rowCount(dev2Idx), 6);
    QCOMPARE(model.index(0, 1, dev2Idx).data(), QStringLiteral("53STGR7-YBM6FCX-PAZ2RHM-YPY6OEJ-WYHVZO7-PCKQRCK-PZLTP7T"));
    QCOMPARE(model.index(2, 1, dev2Idx).data(), QStringLiteral("dynamic, tcp://192.168.1.3:22000"));
}

void ModelTests::testFileModel()
{
    auto row = 0;
    const auto dirId = QStringLiteral("GXWxf-3zgnU");
    const auto *dirInfo = m_connection.findDirInfo(dirId, row);
    QVERIFY(dirInfo);
    QCOMPARE(dirInfo->displayName(), QStringLiteral("A folder"));

    // test behavior of empty/unpopulated model
    auto model = Data::SyncthingFileModel(m_connection, *dirInfo);
    model.setRecursiveSelectionEnabled(true);
    QCOMPARE(model.rowCount(QModelIndex()), 1);
    const auto rootIdx = QPersistentModelIndex(model.index(0, 0));
    QVERIFY(rootIdx.isValid());
    QCOMPARE(rootIdx.data(Data::SyncthingFileModel::NameRole).toString(), dirInfo->displayName());
    QVERIFY(!model.index(1, 0).isValid());
    QCOMPARE(model.rowCount(rootIdx), 1);
    QCOMPARE(model.index(0, 0, rootIdx).data(), QStringLiteral("Loading…"));
    QCOMPARE(model.index(1, 0, rootIdx).data(), QVariant());
    QVERIFY2(!model.canFetchMore(rootIdx), "cannot fetch more when already loading");

    // wait until the root has been updated
    connect(&model, &Data::SyncthingFileModel::fetchQueueEmpty, this, [this]() {
        m_timeout.stop();
        m_loop.quit();
    });
    m_timeout.start();
    m_loop.exec();

    QVERIFY(rootIdx.isValid());
    QCOMPARE(model.rowCount(rootIdx), 2);
    QCOMPARE(rootIdx.sibling(rootIdx.row(), 4).data(Qt::ToolTipRole), QStringLiteral("Exists locally and globally"));

    // test access to nested folders
    const auto androidIdx = QPersistentModelIndex(model.index(0, 0, rootIdx));
    const auto cameraIdx = QPersistentModelIndex(model.index(1, 0, rootIdx));
    const auto nestedIdx = QPersistentModelIndex(model.index(0, 0, cameraIdx));
    const auto initialAndroidPtr = androidIdx.internalPointer();
    const auto initialCameraPtr = cameraIdx.internalPointer();
    QVERIFY(androidIdx.isValid());
    QVERIFY(cameraIdx.isValid());
    QCOMPARE(androidIdx.parent(), rootIdx);
    QCOMPARE(cameraIdx.parent(), rootIdx);
    QCOMPARE(nestedIdx.parent(), cameraIdx);
    QCOMPARE(model.rowCount(androidIdx), 0);
    QCOMPARE(model.rowCount(cameraIdx), 5);
    QCOMPARE(androidIdx.data(), QStringLiteral("100ANDRO"));
    QCOMPARE(cameraIdx.data(), QStringLiteral("Camera"));
    QCOMPARE(model.index(0, 0, cameraIdx).data(), QStringLiteral("IMG_20201114_124821.jpg"));
    QVERIFY(model.index(0, 0, cameraIdx).data(Qt::DecorationRole).canConvert<QIcon>());
    QCOMPARE(model.index(0, 1, cameraIdx).data(), QStringLiteral("10.19 MiB"));
    QCOMPARE(model.index(0, 2, cameraIdx).data(), QStringLiteral("2020-12-16 22:31:34.500"));
    QCOMPARE(model.index(0, 3, cameraIdx).data(), QVariant());
    QVERIFY(model.index(0, 4, cameraIdx).data(Qt::DecorationRole).canConvert<QPixmap>());
    QCOMPARE(model.index(0, 4, cameraIdx).data(Qt::ToolTipRole), QStringLiteral("Exists only globally"));
    QCOMPARE(model.index(1, 0, cameraIdx).data(), QStringLiteral("IMG_20201213_122451.jpg"));
    QCOMPARE(model.index(2, 0, cameraIdx).data(), QStringLiteral("IMG_20201213_122504.jpg"));
    QCOMPARE(model.index(3, 0, cameraIdx).data(), QStringLiteral("IMG_20201213_122505.jpg"));
    QCOMPARE(model.index(4, 0, cameraIdx).data(), QStringLiteral("IMG_20201213_125329.jpg"));
    QCOMPARE(model.index(5, 0, cameraIdx).data(), QVariant());
    QCOMPARE(model.index(5, 0, cameraIdx).data(Qt::DecorationRole), QVariant());
    QCOMPARE(model.index(5, 1, cameraIdx).data(), QVariant());
    QCOMPARE(model.index(5, 2, cameraIdx).data(), QVariant());
    QCOMPARE(model.index(5, 3, cameraIdx).data(), QVariant());
    QCOMPARE(model.index(5, 3, cameraIdx).data(), QVariant());
    QCOMPARE(model.index(5, 4, cameraIdx).data(Qt::DecorationRole), QVariant());
    QCOMPARE(model.index(5, 4, cameraIdx).data(Qt::ToolTipRole), QVariant());

    // test conversion of indexes to/from paths
    const auto testPath = QStringLiteral("Camera/IMG_20201213_122504.jpg");
    const auto testPathIdx = model.index(2, 0, cameraIdx);
    QCOMPARE(model.path(testPathIdx), testPath);
    QCOMPARE(testPathIdx.data(Data::SyncthingFileModel::PathRole), testPath);
    QCOMPARE(model.index(testPath), testPathIdx);

    // re-load the data again and wait for the update
    QVERIFY2(!model.canFetchMore(rootIdx), "cannot fetch more when children already populated");
    model.fetchMore(rootIdx);
    m_timeout.start();
    m_loop.exec();

    // verify that only the root index is still valid (all other indexes have been invalidated)
    QVERIFY(rootIdx.isValid());
    QCOMPARE(model.rowCount(rootIdx), 2);
    QVERIFY(androidIdx.internalPointer() != initialAndroidPtr);
    QVERIFY(!androidIdx.isValid());
    QVERIFY(cameraIdx.internalPointer() != initialCameraPtr);
    QVERIFY(!cameraIdx.isValid());
    QVERIFY(!nestedIdx.isValid());

    // verify that data was re-loaded
    const auto androidIdx2 = QPersistentModelIndex(model.index(0, 0, rootIdx));
    const auto cameraIdx2 = QPersistentModelIndex(model.index(1, 0, rootIdx));
    QCOMPARE(androidIdx2.data(), QStringLiteral("100ANDRO"));
    QCOMPARE(cameraIdx2.data(), QStringLiteral("Camera"));

    // item actions
    QCOMPARE(androidIdx2.data(Data::SyncthingFileModel::Actions).toStringList(),
        QStringList({ QStringLiteral("refresh"), QStringLiteral("toggle-selection-recursively"), QStringLiteral("toggle-selection-single"),
            QStringLiteral("open"), QStringLiteral("copy-path") }));
    QCOMPARE(androidIdx2.data(Data::SyncthingFileModel::ActionNames).toStringList(),
        QStringList({ QStringLiteral("Refresh"), QStringLiteral("Select recursively"), QStringLiteral("Select single item"),
            QStringLiteral("Browse locally"), QStringLiteral("Copy local path") }));
    QCOMPARE(androidIdx2.data(Data::SyncthingFileModel::ActionIcons).toList().size(), 5);

    // selection actions when selection mode disabled
    auto actions = model.selectionActions();
    QVERIFY(!model.isSelectionModeEnabled());
    QCOMPARE(actions.at(0)->text(), QStringLiteral("Select items to sync/ignore"));
    QCOMPARE(actions.at(1)->text(), QStringLiteral("Ignore all items by default"));
    QCOMPARE(actions.size(), 2);
    qDeleteAll(actions);

    // selecting items recursively
    QCOMPARE(rootIdx.data(Qt::CheckStateRole).toInt(), Qt::Unchecked);
    QCOMPARE(androidIdx2.data(Qt::CheckStateRole).toInt(), Qt::Unchecked);
    QVERIFY(model.setData(androidIdx2, Qt::Checked, Qt::CheckStateRole));
    QCOMPARE(rootIdx.data(Qt::CheckStateRole).toInt(), Qt::Unchecked);
    QCOMPARE(androidIdx2.data(Qt::CheckStateRole).toInt(), Qt::Unchecked);
    model.setSelectionModeEnabled(true);
    QCOMPARE(rootIdx.data(Qt::CheckStateRole).toInt(), Qt::PartiallyChecked);
    QCOMPARE(androidIdx2.data(Qt::CheckStateRole).toInt(), Qt::Checked);

    // selection actions when selection mode enabled
    actions = model.selectionActions();
    QCOMPARE(actions.size(), 6);
    QCOMPARE(actions.at(0)->text(), QStringLiteral("Uncheck all and discard staged changes"));
    QCOMPARE(actions.at(1)->text(), QStringLiteral("Ignore checked items (and their children)"));
    QCOMPARE(actions.at(2)->text(), QStringLiteral("Ignore and locally delete checked items (and their children)"));
    QCOMPARE(actions.at(3)->text(), QStringLiteral("Include checked items (and their children)"));
    QCOMPARE(actions.at(4)->text(), QStringLiteral("Ignore all items by default"));
    QCOMPARE(actions.at(5)->text(), QStringLiteral("Remove ignore patterns matching checked items (may affect other items as well)"));
    actions.at(1)->trigger(); // this won't do much in the test setup
    actions.at(0)->trigger(); // disables selection mode
    qDeleteAll(actions);
    QVERIFY(!model.isSelectionModeEnabled());
    QCOMPARE(rootIdx.data(Qt::CheckStateRole).toInt(), Qt::Unchecked);
    QCOMPARE(androidIdx2.data(Qt::CheckStateRole).toInt(), Qt::Unchecked);

    // selecting single items
    model.triggerAction(QStringLiteral("toggle-selection-single"), androidIdx2);
    QVERIFY(model.isSelectionModeEnabled());
    QCOMPARE(rootIdx.data(Qt::CheckStateRole).toInt(), Qt::Unchecked);
    QCOMPARE(androidIdx2.data(Qt::CheckStateRole).toInt(), Qt::Checked);
    QCOMPARE(cameraIdx2.data(Qt::CheckStateRole).toInt(), Qt::Unchecked);

    // select actions recursively via triggerAction() on sibling
    model.triggerAction(QStringLiteral("toggle-selection-recursively"), cameraIdx2);
    QCOMPARE(rootIdx.data(Qt::CheckStateRole).toInt(), Qt::Checked);
    QCOMPARE(androidIdx2.data(Qt::CheckStateRole).toInt(), Qt::Checked);
    QCOMPARE(cameraIdx2.data(Qt::CheckStateRole).toInt(), Qt::Checked);

    // deselecting actions recursively
    model.triggerAction(QStringLiteral("toggle-selection-recursively"), rootIdx);
    QCOMPARE(rootIdx.data(Qt::CheckStateRole).toInt(), Qt::Unchecked);
    QCOMPARE(androidIdx2.data(Qt::CheckStateRole).toInt(), Qt::Unchecked);
    QCOMPARE(cameraIdx2.data(Qt::CheckStateRole).toInt(), Qt::Unchecked);

    // compute diff and new ignore patterns
    const auto testPatterns = QStringList{ QStringLiteral("foo"), QStringLiteral("bar"), QStringLiteral("baz") };
    const auto changedTestPatterns = QStringList{ QStringLiteral("// new comment at beginning"), testPatterns.front(), testPatterns.back(),
        QStringLiteral("biz"), QStringLiteral("buz") };
    model.m_presentIgnorePatterns.reserve(static_cast<std::size_t>(testPatterns.size()));
    for (const auto &pattern : testPatterns) {
        model.m_presentIgnorePatterns.emplace_back(QString(pattern));
    }
    QCOMPARE(model.computeIgnorePatternDiff(), QStringLiteral(" foo\n bar\n baz\n"));
    QCOMPARE(model.computeNewIgnorePatterns().ignore, testPatterns);
    model.m_stagedChanges[std::numeric_limits<std::size_t>::max()].prepend.append(changedTestPatterns.front());
    model.m_stagedChanges[1].replace = true; // removal
    auto &append = model.m_stagedChanges[2];
    append.append << changedTestPatterns.at(3) << changedTestPatterns.at(4);
    QCOMPARE(model.computeIgnorePatternDiff(), QStringLiteral("+// new comment at beginning\n foo\n-bar\n baz\n+biz\n+buz\n"));
    QCOMPARE(model.computeNewIgnorePatterns().ignore, changedTestPatterns);

    // clear all ignore pattern related state; diff and new ignore patterns should be computed to be empty
    model.m_isIgnoringAllByDefault = false;
    model.m_presentIgnorePatterns.clear();
    model.m_stagedChanges.clear();
    model.setCheckState(rootIdx, Qt::Unchecked, true);
    QCOMPARE(model.computeIgnorePatternDiff(), QString());
    QCOMPARE(model.computeNewIgnorePatterns().ignore, QStringList());

    // test ignoring items by default
    actions = model.selectionActions();
    QVERIFY(actions.size() > 4);
    QCOMPARE(actions.at(4)->text(), QStringLiteral("Ignore all items by default"));
    actions.at(4)->trigger();
    auto expectedDiff = QString(QChar('+') % model.m_ignoreAllByDefaultPattern % QChar('\n'));
    auto expectedPatterns = QStringList({ QStringLiteral("/**") });
    QCOMPARE(model.computeIgnorePatternDiff(), expectedDiff);
    QCOMPARE(model.computeNewIgnorePatterns().ignore, expectedPatterns);

    // test including items explicitly
    QCOMPARE(actions.at(3)->text(), QStringLiteral("Include checked items (and their children)"));
    model.setData(androidIdx2, Qt::Checked, Qt::CheckStateRole);
    expectedDiff.prepend(QStringLiteral("+!/100ANDRO\n"));
    expectedPatterns.prepend(QStringLiteral("!/100ANDRO"));
    for (auto i = 0; i != 2; ++i) { // preform action twice; this should not lead to a duplicate
        actions.at(3)->trigger();
        QCOMPARE(model.computeIgnorePatternDiff(), expectedDiff);
        QCOMPARE(model.computeNewIgnorePatterns().ignore, expectedPatterns);
    }
    model.setData(androidIdx2, Qt::Unchecked, Qt::CheckStateRole); // should the item be automatically unchecked?
    model.setData(cameraIdx2, Qt::Checked, Qt::CheckStateRole);
    expectedDiff.insert(QStringLiteral("+!/100ANDRO\n").size(), QStringLiteral("+!/Camera\n"));
    expectedPatterns.insert(1, QStringLiteral("!/Camera"));
    actions.at(3)->trigger();
    QCOMPARE(model.computeIgnorePatternDiff(), expectedDiff);
    QCOMPARE(model.computeNewIgnorePatterns().ignore, expectedPatterns);
    model.setData(cameraIdx2, Qt::Unchecked, Qt::CheckStateRole); // should the item be automatically unchecked?

    // test ignoring items explicitly
    model.setData(model.index(1, 0, cameraIdx2), Qt::Checked, Qt::CheckStateRole);
    model.setData(model.index(3, 0, cameraIdx2), Qt::Checked, Qt::CheckStateRole);
    QCOMPARE(actions.at(1)->text(), QStringLiteral("Ignore checked items (and their children)"));
    actions.at(1)->trigger();
    expectedDiff.insert(
        QStringLiteral("+!/100ANDRO\n").size(), QStringLiteral("+/Camera/IMG_20201213_122451.jpg\n+/Camera/IMG_20201213_122505.jpg\n"));
    expectedPatterns.insert(1, QStringLiteral("/Camera/IMG_20201213_122451.jpg"));
    expectedPatterns.insert(2, QStringLiteral("/Camera/IMG_20201213_122505.jpg"));
    QCOMPARE(model.computeIgnorePatternDiff(), expectedDiff);
    QCOMPARE(model.computeNewIgnorePatterns().ignore, expectedPatterns);

    // test changing default back to including all items by default
    // note: Doing this makes not much sense after including items explicitly like the previous tests just did but this should of course work in general.
    qDeleteAll(actions);
    actions = model.selectionActions();
    QVERIFY(actions.size() > 4);
    QCOMPARE(actions.at(4)->text(), QStringLiteral("Include all items by default"));
    actions.at(4)->trigger();
    expectedDiff.chop(model.m_ignoreAllByDefaultPattern.size() + 2);
    expectedPatterns.removeLast();
    QCOMPARE(model.computeIgnorePatternDiff(), expectedDiff);
    QCOMPARE(model.computeNewIgnorePatterns().ignore, expectedPatterns);

    // test reviewing/applying changes
    qDeleteAll(actions);
    actions = model.selectionActions();
    QVERIFY(actions.size() > 6);
    QCOMPARE(actions.at(6)->text(), QStringLiteral("Review and apply staged changes"));
    connect(
        &model, &Data::SyncthingFileModel::actionNeedsConfirmation, this,
        [&expectedDiff](QAction *action, const QString &message, const QString &diff, const QSet<QString> &localDeletions) {
            QCOMPARE(message, QStringLiteral("Do you want to apply the following changes?"));
            QCOMPARE(diff, expectedDiff);
            QCOMPARE(localDeletions, QSet<QString>());
            action->trigger();
        },
        Qt::QueuedConnection);
    connect(
        &model, &Data::SyncthingFileModel::notification, this,
        [this](const QString &type, const QString &message, const QString &details = QString()) {
            // log but otherwise ignore non-errors
            if (type != QLatin1String("error")) {
                qDebug() << "file model notification of type " << type << ": " << message;
                if (!details.isEmpty()) {
                    qDebug() << "details: " << details;
                }
                return;
            }
            m_timeout.stop();
            m_loop.quit();
            QCOMPARE(details, QString());
            qDebug() << "error message: " << message;
            QVERIFY(message.startsWith(QStringLiteral("Unable to change ignore patterns:")));
        },
        Qt::QueuedConnection);
    actions.at(6)->trigger();
    m_timeout.start();
    m_loop.exec();

    // test changing default back to including all items by default when "ignore all by default" pattern is present from the beginning
    model.m_presentIgnorePatterns.clear();
    model.m_stagedChanges.clear();
    for (const auto &pattern : expectedPatterns) {
        model.m_presentIgnorePatterns.emplace_back(QString(pattern));
    }
    model.m_presentIgnorePatterns.emplace_back(QString(model.m_ignoreAllByDefaultPattern));
    model.m_hasIgnorePatterns = true;
    model.m_isIgnoringAllByDefault = true;
    qDeleteAll(actions);
    actions = model.selectionActions();
    QVERIFY(actions.size() > 5);
    QCOMPARE(actions.at(4)->text(), QStringLiteral("Include all items by default"));
    actions.at(4)->trigger();
    expectedDiff = QStringLiteral(" !/100ANDRO\n /Camera/IMG_20201213_122451.jpg\n /Camera/IMG_20201213_122505.jpg\n !/Camera\n-")
        % model.m_ignoreAllByDefaultPattern % QChar('\n');
    QCOMPARE(model.computeIgnorePatternDiff(), expectedDiff);
    QCOMPARE(model.computeNewIgnorePatterns().ignore, expectedPatterns);

    // test removing matching ignore patterns
    model.resetMatchingIgnorePatterns();
    model.setCheckState(rootIdx, Qt::Unchecked, true);
    model.setCheckState(model.index(1, 0, cameraIdx2), Qt::Checked);
    QCOMPARE(actions.at(5)->text(), QStringLiteral("Remove ignore patterns matching checked items (may affect other items as well)"));
    actions.at(5)->trigger();
    const auto indexInDiff = expectedDiff.indexOf(QStringLiteral(" /Camera/IMG_20201213_122451.jpg"));
    expectedDiff[indexInDiff] = QChar('-');
    expectedPatterns.removeAt(1);
    QCOMPARE(model.computeIgnorePatternDiff(), expectedDiff);
    QCOMPARE(model.computeNewIgnorePatterns().ignore, expectedPatterns);
    // add the pattern back by ignoring the relevant item explicitly
    actions.at(1)->trigger();
    expectedDiff[indexInDiff] = QChar(' ');
    expectedPatterns.insert(1, QStringLiteral("/Camera/IMG_20201213_122451.jpg"));
    QCOMPARE(model.computeIgnorePatternDiff(), expectedDiff);
    QCOMPARE(model.computeNewIgnorePatterns().ignore, expectedPatterns);
    // remove the added-back pattern
    actions.at(5)->trigger();
    expectedDiff[indexInDiff] = QChar('-');
    expectedPatterns.removeAt(1);
    QCOMPARE(model.computeIgnorePatternDiff(), expectedDiff);
    QCOMPARE(model.computeNewIgnorePatterns().ignore, expectedPatterns);
}

QTEST_MAIN(ModelTests)
#include "models.moc"