From: Christian Kamm Date: Wed, 13 Dec 2017 17:04:58 +0000 (+0100) Subject: On-demand downloading: Placeholder-file based prototype X-Git-Tag: archive/raspbian/3.16.7-1_deb13u1+rpi1~1^2~12^2~21^2~468^2~621 X-Git-Url: https://dgit.raspbian.org/?a=commitdiff_plain;h=f5cf03b6a61998f26cffcccb67aa4c75fdb32685;p=nextcloud-desktop.git On-demand downloading: Placeholder-file based prototype - Controled by an option. - New remote files start out as ItemTypePlaceholder, are created with a .owncloud extension. - When their db entry is set to ItemTypePlaceholderDownload the next sync run will download them. - Files that aren't in the placeholder state sync as usual. - See test cases in testsyncplaceholders. Missing: - User ui for triggering placeholder file download - Maybe: Going back from file to placeholder? --- diff --git a/src/csync/csync.h b/src/csync/csync.h index fb9f4682d..db21803ee 100644 --- a/src/csync/csync.h +++ b/src/csync/csync.h @@ -135,7 +135,9 @@ enum ItemType { ItemTypeFile = 0, ItemTypeSoftLink = 1, ItemTypeDirectory = 2, - ItemTypeSkip = 3 + ItemTypeSkip = 3, + ItemTypePlaceholder = 4, + ItemTypePlaceholderDownload = 5 }; diff --git a/src/csync/csync_private.h b/src/csync/csync_private.h index 2345025d2..2ba220358 100644 --- a/src/csync/csync_private.h +++ b/src/csync/csync_private.h @@ -207,6 +207,11 @@ struct OCSYNC_EXPORT csync_s { bool upload_conflict_files = false; + /** + * Whether new remote files should start out as placeholders. + */ + bool new_files_are_placeholders = false; + csync_s(const char *localUri, OCC::SyncJournalDb *statedb); ~csync_s(); int reinitialize(); diff --git a/src/csync/csync_reconcile.cpp b/src/csync/csync_reconcile.cpp index 0681ce4ae..ee0c9b72b 100644 --- a/src/csync/csync_reconcile.cpp +++ b/src/csync/csync_reconcile.cpp @@ -150,6 +150,12 @@ static void _csync_merge_algorithm_visitor(csync_file_stat_t *cur, CSYNC * ctx) cur->instruction = CSYNC_INSTRUCTION_NEW; break; } + if (cur->type == ItemTypePlaceholder && ctx->current == REMOTE_REPLICA) { + /* Do not remove on the server if the local placeholder is gone: + * instead reestablish the local placeholder */ + cur->instruction = CSYNC_INSTRUCTION_NEW; + break; + } cur->instruction = CSYNC_INSTRUCTION_REMOVE; break; case CSYNC_INSTRUCTION_EVAL_RENAME: { @@ -397,7 +403,10 @@ static void _csync_merge_algorithm_visitor(csync_file_stat_t *cur, CSYNC * ctx) cur->instruction = CSYNC_INSTRUCTION_UPDATE_METADATA; other->instruction = CSYNC_INSTRUCTION_NONE; } else { - cur->instruction = CSYNC_INSTRUCTION_SYNC; + if (cur->instruction != CSYNC_INSTRUCTION_NEW + && cur->instruction != CSYNC_INSTRUCTION_SYNC) { + cur->instruction = CSYNC_INSTRUCTION_SYNC; + } other->instruction = CSYNC_INSTRUCTION_NONE; } break; diff --git a/src/csync/csync_update.cpp b/src/csync/csync_update.cpp index d90a1059a..d72a0ec51 100644 --- a/src/csync/csync_update.cpp +++ b/src/csync/csync_update.cpp @@ -214,16 +214,28 @@ static int _csync_detect_update(CSYNC *ctx, std::unique_ptr f qCInfo(lcUpdate, "Database entry found for %s, compare: %" PRId64 " <-> %" PRId64 ", etag: %s <-> %s, inode: %" PRId64 " <-> %" PRId64 ", size: %" PRId64 " <-> %" PRId64 ", perms: %x <-> %x" - ", checksum: %s <-> %s , ignore: %d, e2e: %s", + ", checksum: %s <-> %s, type: %d <-> %d, ignore: %d, e2e: %s", base._path.constData(), ((int64_t) fs->modtime), ((int64_t) base._modtime), fs->etag.constData(), base._etag.constData(), (uint64_t) fs->inode, (uint64_t) base._inode, (uint64_t) fs->size, (uint64_t) base._fileSize, *reinterpret_cast(&fs->remotePerm), *reinterpret_cast(&base._remotePerm), - fs->checksumHeader.constData(), base._checksumHeader.constData(), base._serverHasIgnoredFiles, base._e2eMangledName.constData()); + fs->checksumHeader.constData(), base._checksumHeader.constData(), + fs->type, base._type, + base._serverHasIgnoredFiles, base._e2eMangledName.constData()); + if (ctx->current == REMOTE_REPLICA && base._type == ItemTypePlaceholderDownload) { + fs->instruction = CSYNC_INSTRUCTION_NEW; + fs->type = ItemTypePlaceholderDownload; + goto out; + } + if (ctx->current == REMOTE_REPLICA && fs->etag != base._etag) { fs->instruction = CSYNC_INSTRUCTION_EVAL; - // Preserve the EVAL flag later on if the type has changed. - if (base._type != fs->type) { + if (base._type == ItemTypePlaceholder && fs->type == ItemTypeFile) { + // If the local thing is a placeholder, we just update the metadata + fs->instruction = CSYNC_INSTRUCTION_UPDATE_METADATA; + fs->type = ItemTypePlaceholder; // retain the PLACEHOLDER type in the db + } else if (base._type != fs->type) { + // Preserve the EVAL flag later on if the type has changed. fs->child_modified = true; } @@ -346,10 +358,19 @@ static int _csync_detect_update(CSYNC *ctx, std::unique_ptr f if (!base.isValid()) return; + if (base._type == ItemTypePlaceholderDownload) { + // Remote rename of a placeholder file we have locally scheduled + // for download. We just consider this NEW but mark it for download. + fs->type = ItemTypePlaceholderDownload; + done = true; + return; + } + // Some things prohibit rename detection entirely. // Since we don't do the same checks again in reconcile, we can't // just skip the candidate, but have to give up completely. - if (base._type != fs->type) { + if (base._type != fs->type + && base._type != ItemTypePlaceholder) { qCWarning(lcUpdate, "file types different, not a rename"); done = true; return; @@ -400,6 +421,14 @@ static int _csync_detect_update(CSYNC *ctx, std::unique_ptr f return 1; } } + + // Potentially turn new remote files into placeholders + if (ctx->new_files_are_placeholders + && fs->instruction == CSYNC_INSTRUCTION_NEW + && fs->type == ItemTypeFile) { + fs->type = ItemTypePlaceholder; + } + goto out; } } @@ -502,7 +531,7 @@ int csync_walker(CSYNC *ctx, std::unique_ptr fs) { return rc; } -static bool fill_tree_from_db(CSYNC *ctx, const char *uri) +static bool fill_tree_from_db(CSYNC *ctx, const char *uri, bool singleFile = false) { int64_t count = 0; QByteArray skipbase; @@ -557,9 +586,19 @@ static bool fill_tree_from_db(CSYNC *ctx, const char *uri) ++count; }; - if (!ctx->statedb->getFilesBelowPath(uri, rowCallback)) { - ctx->status_code = CSYNC_STATUS_STATEDB_LOAD_ERROR; - return false; + if (singleFile) { + OCC::SyncJournalFileRecord record; + if (ctx->statedb->getFileRecord(QByteArray(uri), &record) && record.isValid()) { + rowCallback(record); + } else { + ctx->status_code = CSYNC_STATUS_STATEDB_LOAD_ERROR; + return false; + } + } else { + if (!ctx->statedb->getFilesBelowPath(uri, rowCallback)) { + ctx->status_code = CSYNC_STATUS_STATEDB_LOAD_ERROR; + return false; + } } qInfo(lcUpdate, "%" PRId64 " entries read below path %s from db.", count, uri); @@ -710,6 +749,22 @@ int csync_ftw(CSYNC *ctx, const char *uri, csync_walker_fn fn, fullpath = QByteArray() % uri % '/' % filename; } + // When encountering placeholder files, read the relevant + // entry from the db instead. + if (ctx->current == LOCAL_REPLICA + && dirent->type == ItemTypeFile + && filename.endsWith(".owncloud")) { + QByteArray db_uri = fullpath.mid(strlen(ctx->local.uri) + 1); + db_uri = db_uri.left(db_uri.size() - 9); + if( ! fill_tree_from_db(ctx, db_uri.constData(), true) ) { + errno = ENOENT; + ctx->status_code = CSYNC_STATUS_OPENDIR_ERROR; + goto error; + } + + continue; + } + /* if the filename starts with a . we consider it a hidden file * For windows, the hidden state is also discovered within the vio * local stat function. diff --git a/src/libsync/account.h b/src/libsync/account.h index 92008ff08..1f7d9608f 100644 --- a/src/libsync/account.h +++ b/src/libsync/account.h @@ -249,6 +249,9 @@ public: // Check for the directEditing capability void fetchDirectEditors(const QUrl &directEditingURL, const QString &directEditingETag); + bool usePlaceholders() const { return _usePlaceholders; } + void setUsePlaceholders(bool use) { _usePlaceholders = use; } + public slots: /// Used when forgetting credentials void clearQNAMCache(); @@ -345,6 +348,8 @@ private: private: bool _isRemoteWipeRequested_HACK = false; // <-- FIXME MS@2019-12-07 + + bool _usePlaceholders = false; }; } diff --git a/src/libsync/owncloudpropagator.cpp b/src/libsync/owncloudpropagator.cpp index ef41bd004..5d815aa43 100644 --- a/src/libsync/owncloudpropagator.cpp +++ b/src/libsync/owncloudpropagator.cpp @@ -605,6 +605,11 @@ QString OwncloudPropagator::getFilePath(const QString &tmp_file_name) const return _localDir + tmp_file_name; } +QString OwncloudPropagator::placeholderFilePath(const QString &fileName) const +{ + return getFilePath(fileName) + QLatin1String(".owncloud"); +} + void OwncloudPropagator::scheduleNextJob() { QTimer::singleShot(0, this, &OwncloudPropagator::scheduleNextJobImpl); diff --git a/src/libsync/owncloudpropagator.h b/src/libsync/owncloudpropagator.h index f9ebf1938..c6a52db13 100644 --- a/src/libsync/owncloudpropagator.h +++ b/src/libsync/owncloudpropagator.h @@ -453,6 +453,7 @@ public: /* returns the local file path for the given tmp_file_name */ QString getFilePath(const QString &tmp_file_name) const; + QString placeholderFilePath(const QString &fileName) const; /** Creates the job for an item. */ diff --git a/src/libsync/propagatedownload.cpp b/src/libsync/propagatedownload.cpp index 473c5d70b..bf907b2ee 100644 --- a/src/libsync/propagatedownload.cpp +++ b/src/libsync/propagatedownload.cpp @@ -387,6 +387,27 @@ void PropagateDownloadFile::startAfterIsEncryptedIsChecked() { _stopwatch.start(); + // For placeholder files just create the file and be done + if (_item->_type == ItemTypePlaceholder) { + auto fn = propagator()->placeholderFilePath(_item->_file); + qCDebug(lcPropagateDownload) << "creating placeholder file" << fn; + QFile file(fn); + file.open(QFile::ReadWrite); + file.write("stub"); + file.close(); + updateMetadata(false); + return; + } + + // If we want to download something that used to be a placeholder, + // wipe the placeholder and proceed with a normal download + if (_item->_type == ItemTypePlaceholderDownload) { + auto fn = propagator()->placeholderFilePath(_item->_file); + qCDebug(lcPropagateDownload) << "Downloading file that used to be a placeholder" << fn; + QFile::remove(fn); + _item->_type = ItemTypeFile; + } + if (_deleteExisting) { deleteExistingFolder(); diff --git a/src/libsync/propagatorjobs.cpp b/src/libsync/propagatorjobs.cpp index 36c198c4d..0b1aa6194 100644 --- a/src/libsync/propagatorjobs.cpp +++ b/src/libsync/propagatorjobs.cpp @@ -96,6 +96,8 @@ void PropagateLocalRemove::start() return; QString filename = propagator()->_localDir + _item->_file; + if (_item->_type == ItemTypePlaceholder || _item->_type == ItemTypePlaceholderDownload) + filename = propagator()->placeholderFilePath(_item->_file); qCDebug(lcPropagateLocalRemove) << filename; @@ -251,6 +253,11 @@ void PropagateLocalRename::start() QString existingFile = propagator()->getFilePath(_item->_file); QString targetFile = propagator()->getFilePath(_item->_renameTarget); + if (_item->_type == ItemTypePlaceholder || _item->_type == ItemTypePlaceholderDownload) { + existingFile = propagator()->placeholderFilePath(_item->_file); + targetFile = propagator()->placeholderFilePath(_item->_renameTarget); + } + // if the file is a file underneath a moved dir, the _item->file is equal // to _item->renameTarget and the file is not moved as a result. if (_item->_file != _item->_renameTarget) { diff --git a/src/libsync/syncengine.cpp b/src/libsync/syncengine.cpp index fd980e15d..9bd03bc0c 100644 --- a/src/libsync/syncengine.cpp +++ b/src/libsync/syncengine.cpp @@ -621,7 +621,7 @@ int SyncEngine::treewalkFile(csync_file_stat_t *file, csync_file_stat_t *other, if (remote) { QString filePath = _localPath + item->_file; - if (other) { + if (other && other->type != ItemTypePlaceholder && other->type != ItemTypePlaceholderDownload) { // Even if the mtime is different on the server, we always want to keep the mtime from // the file system in the DB, this is to avoid spurious upload on the next sync item->_modtime = other->modtime; @@ -858,6 +858,8 @@ void SyncEngine::startSync() return shouldDiscoverLocally(path); }; + _csync_ctx->new_files_are_placeholders = account()->usePlaceholders(); + // If needed, make sure we have up to date E2E information before the // discovery phase, otherwise we start right away if (_account->capabilities().clientSideEncryptionAvailable()) { diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 864a99bb4..95f1cfc80 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -46,6 +46,7 @@ nextcloud_add_test(ExcludedFiles "") nextcloud_add_test(FileSystem "") nextcloud_add_test(Utility "") nextcloud_add_test(SyncEngine "syncenginetestutils.h") +nextcloud_add_test(SyncPlaceholders "syncenginetestutils.h") nextcloud_add_test(SyncMove "syncenginetestutils.h") nextcloud_add_test(SyncConflict "syncenginetestutils.h") nextcloud_add_test(SyncFileStatusTracker "syncenginetestutils.h") diff --git a/test/testsyncplaceholders.cpp b/test/testsyncplaceholders.cpp new file mode 100644 index 000000000..7d94ee8a5 --- /dev/null +++ b/test/testsyncplaceholders.cpp @@ -0,0 +1,222 @@ +/* + * This software is in the public domain, furnished "as is", without technical + * support, and with no warranty, express or implied, as to its usefulness for + * any purpose. + * + */ + +#include +#include "syncenginetestutils.h" +#include + +using namespace OCC; + +SyncFileItemPtr findItem(const QSignalSpy &spy, const QString &path) +{ + for (const QList &args : spy) { + auto item = args[0].value(); + if (item->destination() == path) + return item; + } + return SyncFileItemPtr(new SyncFileItem); +} + +bool itemInstruction(const QSignalSpy &spy, const QString &path, const csync_instructions_e instr) +{ + auto item = findItem(spy, path); + return item->_instruction == instr; +} + +SyncJournalFileRecord dbRecord(FakeFolder &folder, const QString &path) +{ + SyncJournalFileRecord record; + folder.syncJournal().getFileRecord(path, &record); + return record; +} + +class TestSyncPlaceholders : public QObject +{ + Q_OBJECT + +private slots: + void testPlaceholderLifecycle_data() + { + QTest::addColumn("doLocalDiscovery"); + + QTest::newRow("full local discovery") << true; + QTest::newRow("skip local discovery") << false; + } + + void testPlaceholderLifecycle() + { + QFETCH(bool, doLocalDiscovery); + + FakeFolder fakeFolder{FileInfo()}; + fakeFolder.syncEngine().account()->setUsePlaceholders(true); + QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState()); + QSignalSpy completeSpy(&fakeFolder.syncEngine(), SIGNAL(itemCompleted(const SyncFileItemPtr &))); + + auto cleanup = [&]() { + completeSpy.clear(); + if (!doLocalDiscovery) + fakeFolder.syncEngine().setLocalDiscoveryOptions(LocalDiscoveryStyle::DatabaseAndFilesystem); + }; + cleanup(); + + // Create a placeholder for a new remote file + fakeFolder.remoteModifier().mkdir("A"); + fakeFolder.remoteModifier().insert("A/a1", 64); + QVERIFY(fakeFolder.syncOnce()); + QVERIFY(!fakeFolder.currentLocalState().find("A/a1")); + QVERIFY(fakeFolder.currentLocalState().find("A/a1.owncloud")); + QVERIFY(fakeFolder.currentRemoteState().find("A/a1")); + QVERIFY(itemInstruction(completeSpy, "A/a1", CSYNC_INSTRUCTION_NEW)); + QCOMPARE(dbRecord(fakeFolder, "A/a1")._type, ItemTypePlaceholder); + cleanup(); + + // Another sync doesn't actually lead to changes + QVERIFY(fakeFolder.syncOnce()); + QVERIFY(!fakeFolder.currentLocalState().find("A/a1")); + QVERIFY(fakeFolder.currentLocalState().find("A/a1.owncloud")); + QVERIFY(fakeFolder.currentRemoteState().find("A/a1")); + QVERIFY(completeSpy.isEmpty()); + cleanup(); + + // Neither does a remote change + fakeFolder.remoteModifier().appendByte("A/a1"); + QVERIFY(fakeFolder.syncOnce()); + QVERIFY(!fakeFolder.currentLocalState().find("A/a1")); + QVERIFY(fakeFolder.currentLocalState().find("A/a1.owncloud")); + QVERIFY(fakeFolder.currentRemoteState().find("A/a1")); + QVERIFY(itemInstruction(completeSpy, "A/a1", CSYNC_INSTRUCTION_UPDATE_METADATA)); + QCOMPARE(dbRecord(fakeFolder, "A/a1")._type, ItemTypePlaceholder); + QCOMPARE(dbRecord(fakeFolder, "A/a1")._fileSize, 65); + cleanup(); + + // If the local placeholder file is removed, it'll just be recreated + if (!doLocalDiscovery) + fakeFolder.syncEngine().setLocalDiscoveryOptions(LocalDiscoveryStyle::DatabaseAndFilesystem, { "A" }); + fakeFolder.localModifier().remove("A/a1.owncloud"); + QVERIFY(fakeFolder.syncOnce()); + QVERIFY(!fakeFolder.currentLocalState().find("A/a1")); + QVERIFY(fakeFolder.currentLocalState().find("A/a1.owncloud")); + QVERIFY(fakeFolder.currentRemoteState().find("A/a1")); + QVERIFY(itemInstruction(completeSpy, "A/a1", CSYNC_INSTRUCTION_NEW)); + QCOMPARE(dbRecord(fakeFolder, "A/a1")._type, ItemTypePlaceholder); + QCOMPARE(dbRecord(fakeFolder, "A/a1")._fileSize, 65); + cleanup(); + + // Remote rename is propagated + fakeFolder.remoteModifier().rename("A/a1", "A/a1m"); + QVERIFY(fakeFolder.syncOnce()); + QVERIFY(!fakeFolder.currentLocalState().find("A/a1")); + QVERIFY(!fakeFolder.currentLocalState().find("A/a1m")); + QVERIFY(!fakeFolder.currentLocalState().find("A/a1.owncloud")); + QVERIFY(fakeFolder.currentLocalState().find("A/a1m.owncloud")); + QVERIFY(!fakeFolder.currentRemoteState().find("A/a1")); + QVERIFY(fakeFolder.currentRemoteState().find("A/a1m")); + QVERIFY(itemInstruction(completeSpy, "A/a1m", CSYNC_INSTRUCTION_RENAME)); + QCOMPARE(dbRecord(fakeFolder, "A/a1m")._type, ItemTypePlaceholder); + cleanup(); + + // Remote remove is propagated + fakeFolder.remoteModifier().remove("A/a1m"); + QVERIFY(fakeFolder.syncOnce()); + QVERIFY(!fakeFolder.currentLocalState().find("A/a1m.owncloud")); + QVERIFY(!fakeFolder.currentRemoteState().find("A/a1m")); + QVERIFY(itemInstruction(completeSpy, "A/a1m", CSYNC_INSTRUCTION_REMOVE)); + QVERIFY(!dbRecord(fakeFolder, "A/a1m").isValid()); + cleanup(); + } + + void testWithNormalSync() + { + FakeFolder fakeFolder{FileInfo::A12_B12_C12_S12()}; + fakeFolder.syncEngine().account()->setUsePlaceholders(true); + QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState()); + QSignalSpy completeSpy(&fakeFolder.syncEngine(), SIGNAL(itemCompleted(const SyncFileItemPtr &))); + + auto cleanup = [&]() { + completeSpy.clear(); + }; + cleanup(); + + // No effect sync + QVERIFY(fakeFolder.syncOnce()); + QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState()); + cleanup(); + + // Existing files are propagated just fine in both directions + fakeFolder.localModifier().appendByte("A/a1"); + fakeFolder.localModifier().insert("A/a3"); + fakeFolder.remoteModifier().appendByte("A/a2"); + QVERIFY(fakeFolder.syncOnce()); + QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState()); + cleanup(); + + // New files on the remote create placeholders + fakeFolder.remoteModifier().insert("A/new"); + QVERIFY(fakeFolder.syncOnce()); + QVERIFY(!fakeFolder.currentLocalState().find("A/new")); + QVERIFY(fakeFolder.currentLocalState().find("A/new.owncloud")); + QVERIFY(fakeFolder.currentRemoteState().find("A/new")); + QVERIFY(itemInstruction(completeSpy, "A/new", CSYNC_INSTRUCTION_NEW)); + QCOMPARE(dbRecord(fakeFolder, "A/new")._type, ItemTypePlaceholder); + cleanup(); + } + + void testPlaceholderDownload() + { + FakeFolder fakeFolder{FileInfo()}; + fakeFolder.syncEngine().account()->setUsePlaceholders(true); + QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState()); + QSignalSpy completeSpy(&fakeFolder.syncEngine(), SIGNAL(itemCompleted(const SyncFileItemPtr &))); + + auto cleanup = [&]() { + completeSpy.clear(); + }; + cleanup(); + + auto triggerDownload = [&](const QByteArray &path) { + auto &journal = fakeFolder.syncJournal(); + SyncJournalFileRecord record; + journal.getFileRecord(path, &record); + if (!record.isValid()) + return; + record._type = ItemTypePlaceholderDownload; + journal.setFileRecord(record); + }; + + // Create a placeholder for remote files + fakeFolder.remoteModifier().mkdir("A"); + fakeFolder.remoteModifier().insert("A/a1"); + fakeFolder.remoteModifier().insert("A/a2"); + fakeFolder.remoteModifier().insert("A/a3"); + fakeFolder.remoteModifier().insert("A/a4"); + QVERIFY(fakeFolder.syncOnce()); + QVERIFY(fakeFolder.currentLocalState().find("A/a1.owncloud")); + QVERIFY(fakeFolder.currentLocalState().find("A/a2.owncloud")); + QVERIFY(fakeFolder.currentLocalState().find("A/a3.owncloud")); + QVERIFY(fakeFolder.currentLocalState().find("A/a4.owncloud")); + cleanup(); + + // Download by changing the db entry + triggerDownload("A/a1"); + triggerDownload("A/a2"); + triggerDownload("A/a3"); + triggerDownload("A/a4"); + fakeFolder.remoteModifier().appendByte("A/a2"); + fakeFolder.remoteModifier().remove("A/a3"); + fakeFolder.remoteModifier().rename("A/a4", "A/a4m"); + QVERIFY(fakeFolder.syncOnce()); + QVERIFY(itemInstruction(completeSpy, "A/a1", CSYNC_INSTRUCTION_NEW)); + QVERIFY(itemInstruction(completeSpy, "A/a2", CSYNC_INSTRUCTION_NEW)); + QVERIFY(itemInstruction(completeSpy, "A/a3", CSYNC_INSTRUCTION_REMOVE)); + QVERIFY(itemInstruction(completeSpy, "A/a4", CSYNC_INSTRUCTION_REMOVE)); + QVERIFY(itemInstruction(completeSpy, "A/a4m", CSYNC_INSTRUCTION_NEW)); + QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState()); + } +}; + +QTEST_GUILESS_MAIN(TestSyncPlaceholders) +#include "testsyncplaceholders.moc"