diff --git a/src/akonadi/akonadilivequeryhelpers.h b/src/akonadi/akonadilivequeryhelpers.h --- a/src/akonadi/akonadilivequeryhelpers.h +++ b/src/akonadi/akonadilivequeryhelpers.h @@ -28,6 +28,7 @@ #include "akonadi/akonadistorageinterface.h" #include "domain/livequery.h" +#include "domain/task.h" namespace Akonadi { @@ -50,6 +51,10 @@ ItemFetchFunction fetchItems(const Collection &collection) const; ItemFetchFunction fetchItems(const Tag &tag) const; + /// Returns a fetch function which calls a LiveQueryInput::AddFunction (provided as argument to the fetch function) + /// with the given task, then its parent, its grandparent etc. up until the project. + ItemFetchFunction fetchTaskAndAncestors(Domain::Task::Ptr task) const; + ItemFetchFunction fetchSiblings(const Item &item) const; TagFetchFunction fetchTags() const; diff --git a/src/akonadi/akonadilivequeryhelpers.cpp b/src/akonadi/akonadilivequeryhelpers.cpp --- a/src/akonadi/akonadilivequeryhelpers.cpp +++ b/src/akonadi/akonadilivequeryhelpers.cpp @@ -150,6 +150,49 @@ #endif } +LiveQueryHelpers::ItemFetchFunction LiveQueryHelpers::fetchTaskAndAncestors(Domain::Task::Ptr task) const +{ + Akonadi::Item childItem = m_serializer->createItemFromTask(task); + Q_ASSERT(childItem.parentCollection().isValid()); // do I really need a fetchItem first, like fetchSiblings does? + // Note: if the task moves to another collection, this live query will then be invalid... + + const Akonadi::Item::Id childId = childItem.id(); + auto storage = m_storage; + auto serializer = m_serializer; + return [storage, serializer, childItem, childId] (const Domain::LiveQueryInput::AddFunction &add) { + auto job = storage->fetchItems(childItem.parentCollection()); + Utils::JobHandler::install(job->kjob(), [job, add, serializer, childId] { + if (job->kjob()->error() != KJob::NoError) + return; + + const auto items = job->items(); + // The item itself is part of the result, we need that in findProject, to react on changes of the item itself + // To return a correct child item in case it got updated, we can't use childItem, we need to find it in the list. + const auto myself = std::find_if(items.cbegin(), items.cend(), + [childId] (const Akonadi::Item &item) { + return childId == item.id(); + }); + if (myself == items.cend()) { + qWarning() << "Did not find item in the listing for its parent collection. Item ID:" << childId; + return; + } + add(*myself); + auto parentUid = serializer->relatedUidFromItem(*myself); + while (!parentUid.isEmpty()) { + const auto parent = std::find_if(items.cbegin(), items.cend(), + [serializer, parentUid] (const Akonadi::Item &item) { + return serializer->itemUid(item) == parentUid; + }); + if (parent == items.cend()) { + break; + } + add(*parent); + parentUid = serializer->relatedUidFromItem(*parent); + } + }); + }; +} + LiveQueryHelpers::ItemFetchFunction LiveQueryHelpers::fetchSiblings(const Item &item) const { auto storage = m_storage; diff --git a/src/akonadi/akonadilivequeryintegrator.h b/src/akonadi/akonadilivequeryintegrator.h --- a/src/akonadi/akonadilivequeryintegrator.h +++ b/src/akonadi/akonadilivequeryintegrator.h @@ -108,7 +108,7 @@ typedef UnaryFunctionTraits AddTraits; typedef UnaryFunctionTraits PredicateTraits; - typedef typename std::decay::type InputType; + typedef typename std::decay::type InputType; // typically Akonadi::Item static_assert(std::is_same::value, "Fetch function must return void"); @@ -139,6 +139,49 @@ output = query; } + template + void bindRelationship(const QByteArray &debugName, + QSharedPointer> &output, + FetchFunction fetch, + CompareFunction compare, + PredicateFunction predicate, + ExtraArgs... extra) + { + typedef UnaryFunctionTraits FetchTraits; + typedef UnaryFunctionTraits AddTraits; + typedef UnaryFunctionTraits PredicateTraits; + + typedef typename std::decay::type InputType; // typically Akonadi::Item + + static_assert(std::is_same::value, + "Fetch function must return void"); + static_assert(std::is_same::value, + "Fetch add function must return void"); + static_assert(std::is_same::value, + "Predicate function must return bool"); + + typedef typename std::decay::type AddInputType; + static_assert(std::is_same::value, + "Fetch add and predicate functions must have the same input type"); + + if (output) + return; + + using namespace std::placeholders; + + auto query = Domain::LiveRelationshipQuery::Ptr::create(); + + query->setDebugName(debugName); + query->setFetchFunction(fetch); + query->setCompareFunction(compare); + query->setPredicateFunction(predicate); + query->setConvertFunction(std::bind(&LiveQueryIntegrator::create, this, _1, extra...)); + query->setRepresentsFunction(std::bind(&LiveQueryIntegrator::represents, this, _1, _2)); + + inputQueries() << query; + output = query; + } + void addRemoveHandler(const CollectionRemoveHandler &handler); void addRemoveHandler(const ItemRemoveHandler &handler); void addRemoveHandler(const TagRemoveHandler &handler); diff --git a/src/akonadi/akonadiserializer.cpp b/src/akonadi/akonadiserializer.cpp --- a/src/akonadi/akonadiserializer.cpp +++ b/src/akonadi/akonadiserializer.cpp @@ -67,7 +67,7 @@ QString Serializer::itemUid(const Item &item) { - if (isTaskItem(item)) { + if (item.hasPayload()) { const auto todo = item.payload(); return todo->uid(); } else { diff --git a/src/akonadi/akonaditaskqueries.h b/src/akonadi/akonaditaskqueries.h --- a/src/akonadi/akonaditaskqueries.h +++ b/src/akonadi/akonaditaskqueries.h @@ -48,6 +48,9 @@ typedef Domain::QueryResultProvider ContextProvider; typedef Domain::QueryResult ContextResult; + typedef Domain::QueryResult ProjectResult; + typedef Domain::LiveQueryOutput ProjectQueryOutput; + TaskQueries(const StorageInterface::Ptr &storage, const SerializerInterface::Ptr &serializer, const MonitorInterface::Ptr &monitor, @@ -62,6 +65,7 @@ TaskResult::Ptr findInboxTopLevel() const Q_DECL_OVERRIDE; TaskResult::Ptr findWorkdayTopLevel() const Q_DECL_OVERRIDE; ContextResult::Ptr findContexts(Domain::Task::Ptr task) const Q_DECL_OVERRIDE; + ProjectResult::Ptr findProject(Domain::Task::Ptr task) const Q_DECL_OVERRIDE; private slots: void onWorkdayPollTimeout(); @@ -76,6 +80,7 @@ mutable TaskQueryOutput::Ptr m_findAll; mutable QHash m_findChildren; + mutable QHash m_findProject; mutable TaskQueryOutput::Ptr m_findTopLevel; mutable TaskQueryOutput::Ptr m_findInboxTopLevel; mutable TaskQueryOutput::Ptr m_findWorkdayTopLevel; diff --git a/src/akonadi/akonaditaskqueries.cpp b/src/akonadi/akonaditaskqueries.cpp --- a/src/akonadi/akonaditaskqueries.cpp +++ b/src/akonadi/akonaditaskqueries.cpp @@ -80,6 +80,21 @@ return query->result(); } +TaskQueries::ProjectResult::Ptr TaskQueries::findProject(Domain::Task::Ptr task) const +{ + Akonadi::Item childItem = m_serializer->createItemFromTask(task); + auto &query = m_findProject[childItem.id()]; + auto fetch = m_helpers->fetchTaskAndAncestors(task); + auto predicate = [this, childItem] (const Akonadi::Item &item) { + return m_serializer->isProjectItem(item); + }; + auto compare = [] (const Akonadi::Item &item1, const Akonadi::Item &item2) { + return item1.id() == item2.id(); + }; + m_integrator->bindRelationship("TaskQueries::findProject", query, fetch, compare, predicate); + return query->result(); +} + TaskQueries::TaskResult::Ptr TaskQueries::findTopLevel() const { auto fetch = m_helpers->fetchItems(StorageInterface::Tasks); diff --git a/src/domain/livequery.h b/src/domain/livequery.h --- a/src/domain/livequery.h +++ b/src/domain/livequery.h @@ -263,6 +263,171 @@ typename Provider::WeakPtr m_provider; }; +// A query that stores an intermediate list of results (from the fetch), to react on changes on any item in that list +// and then filters that list with the predicate for the final result +// When one of the intermediary items changes, a full fetch is done again. +template +class LiveRelationshipQuery : public LiveQueryInput, public LiveQueryOutput +{ +public: + typedef QSharedPointer> Ptr; + typedef QList List; + + typedef QueryResultProvider Provider; + typedef QueryResult Result; + + typedef typename LiveQueryInput::AddFunction AddFunction; + typedef typename LiveQueryInput::FetchFunction FetchFunction; + typedef typename LiveQueryInput::PredicateFunction PredicateFunction; + + typedef std::function ConvertFunction; + typedef std::function RepresentsFunction; + typedef std::function CompareFunction; + + LiveRelationshipQuery() = default; + LiveRelationshipQuery(const LiveRelationshipQuery &other) = default; + LiveRelationshipQuery &operator=(const LiveRelationshipQuery &other) = default; + + ~LiveRelationshipQuery() + { + clear(); + } + + typename Result::Ptr result() Q_DECL_OVERRIDE + { + typename Provider::Ptr provider(m_provider.toStrongRef()); + + if (provider) + return Result::create(provider); + provider = Provider::Ptr::create(); + m_provider = provider.toWeakRef(); + + doFetch(); + + return Result::create(provider); + } + + void setFetchFunction(const FetchFunction &fetch) + { + m_fetch = fetch; + } + + void setPredicateFunction(const PredicateFunction &predicate) + { + m_predicate = predicate; + } + + void setCompareFunction(const CompareFunction &compare) + { + m_compare = compare; + } + + void setConvertFunction(const ConvertFunction &convert) + { + m_convert = convert; + } + + void setDebugName(const QByteArray &name) + { + m_debugName = name; + } + + void setRepresentsFunction(const RepresentsFunction &represents) + { + m_represents = represents; + } + + void reset() Q_DECL_OVERRIDE + { + clear(); + doFetch(); + } + + void onAdded(const InputType &input) Q_DECL_OVERRIDE + { + typename Provider::Ptr provider(m_provider.toStrongRef()); + + if (!provider) + return; + + m_intermediaryResults.append(input); + if (m_predicate(input)) + addToProvider(provider, input); + } + + void onChanged(const InputType &input) Q_DECL_OVERRIDE + { + Q_ASSERT(m_compare); + const bool found = std::any_of(m_intermediaryResults.constBegin(), m_intermediaryResults.constEnd(), + [&input, this](const InputType &existing) { + return m_compare(input, existing); + }); + if (found) + reset(); + } + + void onRemoved(const InputType &input) Q_DECL_OVERRIDE + { + onChanged(input); + } + +private: + template + bool isValidOutput(const T &/*output*/) + { + return true; + } + + template + bool isValidOutput(const QSharedPointer &output) + { + return !output.isNull(); + } + + template + bool isValidOutput(T *output) + { + return output != Q_NULLPTR; + } + + void addToProvider(const typename Provider::Ptr &provider, const InputType &input) + { + auto output = m_convert(input); + if (isValidOutput(output)) + provider->append(output); + } + + void doFetch() + { + auto addFunction = [this] (const InputType &input) { + onAdded(input); + }; + m_fetch(addFunction); + } + + void clear() + { + m_intermediaryResults.clear(); + + typename Provider::Ptr provider(m_provider.toStrongRef()); + + if (!provider) + return; + + while (!provider->data().isEmpty()) + provider->removeFirst(); + } + + FetchFunction m_fetch; + PredicateFunction m_predicate; + ConvertFunction m_convert; + CompareFunction m_compare; + RepresentsFunction m_represents; + QByteArray m_debugName; + + typename Provider::WeakPtr m_provider; + QList m_intermediaryResults; +}; } diff --git a/src/domain/taskqueries.h b/src/domain/taskqueries.h --- a/src/domain/taskqueries.h +++ b/src/domain/taskqueries.h @@ -27,6 +27,7 @@ #include "context.h" #include "queryresult.h" #include "task.h" +#include "project.h" namespace Domain { @@ -49,6 +50,8 @@ virtual QueryResult::Ptr findWorkdayTopLevel() const = 0; virtual QueryResult::Ptr findContexts(Task::Ptr task) const = 0; + + virtual QueryResult::Ptr findProject(Task::Ptr task) const = 0; }; } diff --git a/src/presentation/workdaypagemodel.h b/src/presentation/workdaypagemodel.h --- a/src/presentation/workdaypagemodel.h +++ b/src/presentation/workdaypagemodel.h @@ -36,6 +36,7 @@ { Q_OBJECT public: + enum { ProjectRole = 0x386F4B }; explicit WorkdayPageModel(const Domain::TaskQueries::Ptr &taskQueries, const Domain::TaskRepository::Ptr &taskRepository, QObject *parent = Q_NULLPTR); diff --git a/src/presentation/workdaypagemodel.cpp b/src/presentation/workdaypagemodel.cpp --- a/src/presentation/workdaypagemodel.cpp +++ b/src/presentation/workdaypagemodel.cpp @@ -105,20 +105,36 @@ return artifact.dynamicCast() ? (defaultFlags | Qt::ItemIsUserCheckable | Qt::ItemIsDropEnabled) : defaultFlags; }; - auto data = [](const Domain::Artifact::Ptr &artifact, int role) -> QVariant { - if (role != Qt::DisplayRole - && role != Qt::EditRole - && role != Qt::CheckStateRole) { - return QVariant(); - } - - if (role == Qt::DisplayRole || role == Qt::EditRole) { - return artifact->title(); - } else if (auto task = artifact.dynamicCast()) { - return task->isDone() ? Qt::Checked : Qt::Unchecked; - } else { - return QVariant(); + auto data = [this](const Domain::Artifact::Ptr &artifact, int role) -> QVariant { + switch (role) { + case Qt::DisplayRole: + case Qt::EditRole: + return artifact->title(); + case Qt::CheckStateRole: + if (auto task = artifact.dynamicCast()) { + return task->isDone() ? Qt::Checked : Qt::Unchecked; + } + break; + case ProjectRole: + case Qt::ToolTipRole: + if (auto task = artifact.dynamicCast()) { + static Domain::QueryResult::Ptr lastProjectResult; + auto projectResult = m_taskQueries->findProject(task); + if (projectResult) { + // keep a refcount to it, for next time we get here... + lastProjectResult = projectResult; + if (!projectResult->data().isEmpty()) { + Domain::Project::Ptr project = projectResult->data().at(0); + return i18n("Project: %1", project->name()); + } + } + return i18n("Inbox"); + } + break; + default: + break; } + return QVariant(); }; auto setData = [this](const Domain::Artifact::Ptr &artifact, const QVariant &value, int role) { diff --git a/tests/units/akonadi/akonaditaskqueriestest.cpp b/tests/units/akonadi/akonaditaskqueriestest.cpp --- a/tests/units/akonadi/akonaditaskqueriestest.cpp +++ b/tests/units/akonadi/akonaditaskqueriestest.cpp @@ -26,6 +26,7 @@ #include "akonadi/akonadicachingstorage.h" #include "akonadi/akonaditaskqueries.h" #include "akonadi/akonadiserializer.h" +#include "akonadi/akonadiitemfetchjobinterface.h" #include "testlib/akonadifakedata.h" #include "testlib/gencollection.h" @@ -1616,6 +1617,201 @@ QCOMPARE(result->data().size(), 1); QCOMPARE(result->data().at(0)->title(), QStringLiteral("43")); } + + void findProjectShouldLookInCollection() + { + // GIVEN + AkonadiFakeData data; + + // One top level collection + auto collection = GenCollection().withId(42).withRootAsParent().withTaskContent(); + data.createCollection(collection); + + // Three tasks in the collection (two being children of the first one) + data.createItem(GenTodo().withId(42).asProject().withParent(42) + .withTitle(QStringLiteral("42")).withUid(QStringLiteral("uid-42"))); + data.createItem(GenTodo().withId(43).withParent(42) + .withTitle(QStringLiteral("43")).withUid(QStringLiteral("uid-43")) + .withParentUid(QStringLiteral("uid-42"))); + data.createItem(GenTodo().withId(44).withParent(42) + .withTitle(QStringLiteral("44")).withUid(QStringLiteral("uid-44")) + .withParentUid(QStringLiteral("uid-42"))); + + // WHEN + auto serializer = Akonadi::Serializer::Ptr(new Akonadi::Serializer); + + auto cache = Akonadi::Cache::Ptr::create(serializer, Akonadi::MonitorInterface::Ptr(data.createMonitor())); + auto storage = createCachingStorage(data, cache); + QScopedPointer queries(new Akonadi::TaskQueries(storage, + serializer, + Akonadi::MonitorInterface::Ptr(data.createMonitor()), + cache)); + auto task = serializer->createTaskFromItem(data.item(44)); + // populate cache for collection + auto *fetchJob = storage->fetchItems(collection); + QVERIFY2(fetchJob->kjob()->exec(), qPrintable(fetchJob->kjob()->errorString())); + + auto result = queries->findProject(task); + + // THEN + QVERIFY(result); + TestHelpers::waitForEmptyJobQueue(); + QCOMPARE(result->data().size(), 1); + QCOMPARE(result->data().at(0)->name(), QStringLiteral("42")); + + // Should not change anything + result = queries->findProject(task); + + QCOMPARE(result->data().size(), 1); + QCOMPARE(result->data().at(0)->name(), QStringLiteral("42")); + } + + void findProjectShouldReactToRelationshipChange() + { + // GIVEN + AkonadiFakeData data; + + // One top level collection + const Akonadi::Collection::Id colId = 42; + auto collection = GenCollection().withId(colId).withRootAsParent().withTaskContent(); + data.createCollection(collection); + + // Three tasks in the collection (two being children of the first one) + // 1->2->3 (project) where 1 changes to 1->4->5 (project) + data.createItem(GenTodo().withId(3).asProject().withParent(colId) + .withTitle(QStringLiteral("Project 3")).withUid(QStringLiteral("uid-3"))); + data.createItem(GenTodo().withId(2).withParent(colId) + .withTitle(QStringLiteral("Intermediate item 2")).withUid(QStringLiteral("uid-2")) + .withParentUid(QStringLiteral("uid-3"))); + data.createItem(GenTodo().withId(1).withParent(colId) + .withTitle(QStringLiteral("Item 1")).withUid(QStringLiteral("uid-1")) + .withParentUid(QStringLiteral("uid-2"))); + data.createItem(GenTodo().withId(5).asProject().withParent(colId) + .withTitle(QStringLiteral("Project 5")).withUid(QStringLiteral("uid-5"))); + data.createItem(GenTodo().withId(4).withParent(colId) + .withTitle(QStringLiteral("Intermediate item 4")).withUid(QStringLiteral("uid-4")) + .withParentUid(QStringLiteral("uid-5"))); + + auto serializer = Akonadi::Serializer::Ptr(new Akonadi::Serializer); + + auto cache = Akonadi::Cache::Ptr::create(serializer, Akonadi::MonitorInterface::Ptr(data.createMonitor())); + auto storage = createCachingStorage(data, cache); + QScopedPointer queries(new Akonadi::TaskQueries(storage, + serializer, + Akonadi::MonitorInterface::Ptr(data.createMonitor()), + cache)); + auto task = serializer->createTaskFromItem(data.item(1)); + + auto result = queries->findProject(task); + + QVERIFY(result); + TestHelpers::waitForEmptyJobQueue(); + QCOMPARE(result->data().size(), 1); + QCOMPARE(result->data().at(0)->name(), QStringLiteral("Project 3")); + + // WHEN + data.modifyItem(GenTodo(data.item(1)).withParentUid(QStringLiteral("uid-4"))); + + // THEN + TestHelpers::waitForEmptyJobQueue(); + QCOMPARE(result->data().size(), 1); + QCOMPARE(result->data().at(0)->name(), QStringLiteral("Project 5")); + } + + void findProjectShouldReactToIntermediateParentChange() + { + // GIVEN + AkonadiFakeData data; + + // One top level collection + const Akonadi::Collection::Id colId = 42; + data.createCollection(GenCollection().withId(colId).withRootAsParent().withTaskContent()); + + // Three tasks in the collection (two being children of the first one) + // 1->2->3 (project) where 2 changes to 1->2->4 (project) + data.createItem(GenTodo().withId(3).asProject().withParent(colId) + .withTitle(QStringLiteral("Project 3")).withUid(QStringLiteral("uid-3"))); + data.createItem(GenTodo().withId(2).withParent(colId) + .withTitle(QStringLiteral("Intermediate item 2")).withUid(QStringLiteral("uid-2")) + .withParentUid(QStringLiteral("uid-3"))); + data.createItem(GenTodo().withId(1).withParent(colId) + .withTitle(QStringLiteral("Item 1")).withUid(QStringLiteral("uid-1")) + .withParentUid(QStringLiteral("uid-2"))); + data.createItem(GenTodo().withId(4).asProject().withParent(colId) + .withTitle(QStringLiteral("Project 4")).withUid(QStringLiteral("uid-4"))); + + auto serializer = Akonadi::Serializer::Ptr(new Akonadi::Serializer); + + auto cache = Akonadi::Cache::Ptr::create(serializer, Akonadi::MonitorInterface::Ptr(data.createMonitor())); + auto storage = createCachingStorage(data, cache); + QScopedPointer queries(new Akonadi::TaskQueries(storage, + serializer, + Akonadi::MonitorInterface::Ptr(data.createMonitor()), + cache)); + auto task = serializer->createTaskFromItem(data.item(1)); + + auto result = queries->findProject(task); + + QVERIFY(result); + TestHelpers::waitForEmptyJobQueue(); + QCOMPARE(result->data().size(), 1); + QCOMPARE(result->data().at(0)->name(), QStringLiteral("Project 3")); + + // WHEN + data.modifyItem(GenTodo(data.item(2)).withParentUid(QStringLiteral("uid-4"))); + + // THEN + TestHelpers::waitForEmptyJobQueue(); + QCOMPARE(result->data().size(), 1); + QCOMPARE(result->data().at(0)->name(), QStringLiteral("Project 4")); + + // AND WHEN + data.removeItem(GenTodo(data.item(2))); + + // THEN + TestHelpers::waitForEmptyJobQueue(); + QCOMPARE(result->data().size(), 0); + } + + void findProjectShouldReactToChildItemRemoved() + { + // GIVEN + AkonadiFakeData data; + + // One top level collection + const Akonadi::Collection::Id colId = 42; + data.createCollection(GenCollection().withId(colId).withRootAsParent().withTaskContent()); + + // Three task in the collection: 1->2(project) + data.createItem(GenTodo().withId(2).asProject().withParent(colId) + .withTitle(QStringLiteral("Project 2")).withUid(QStringLiteral("uid-2"))); + data.createItem(GenTodo().withId(1).withParent(colId) + .withTitle(QStringLiteral("Item 1")).withUid(QStringLiteral("uid-1")) + .withParentUid(QStringLiteral("uid-2"))); + + auto serializer = Akonadi::Serializer::Ptr(new Akonadi::Serializer); + + auto cache = Akonadi::Cache::Ptr::create(serializer, Akonadi::MonitorInterface::Ptr(data.createMonitor())); + auto storage = createCachingStorage(data, cache); + QScopedPointer queries(new Akonadi::TaskQueries(storage, + serializer, + Akonadi::MonitorInterface::Ptr(data.createMonitor()), + cache)); + auto task = serializer->createTaskFromItem(data.item(1)); + auto result = queries->findProject(task); + QVERIFY(result); + TestHelpers::waitForEmptyJobQueue(); + QCOMPARE(result->data().size(), 1); + QCOMPARE(result->data().at(0)->name(), QStringLiteral("Project 2")); + + // WHEN + data.removeItem(Akonadi::Item(1)); + + // THEN + TestHelpers::waitForEmptyJobQueue(); + QCOMPARE(result->data().size(), 0); + } + }; ZANSHIN_TEST_MAIN(AkonadiTaskQueriesTest) diff --git a/tests/units/domain/CMakeLists.txt b/tests/units/domain/CMakeLists.txt --- a/tests/units/domain/CMakeLists.txt +++ b/tests/units/domain/CMakeLists.txt @@ -3,6 +3,7 @@ contexttest datasourcetest livequerytest + liverelationshipquerytest mockitotest notetest projecttest diff --git a/tests/units/domain/liverelationshipquerytest.cpp b/tests/units/domain/liverelationshipquerytest.cpp new file mode 100644 --- /dev/null +++ b/tests/units/domain/liverelationshipquerytest.cpp @@ -0,0 +1,483 @@ +/* This file is part of Zanshin + + Copyright 2014 Kevin Ottens + Copyright 2018 David Faure + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of + the License or (at your option) version 3 or any later version + accepted by the membership of KDE e.V. (or its successor approved + by the membership of KDE e.V.), which shall act as a proxy + defined in Section 14 of version 3 of the license. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, + USA. +*/ + +#include + +#include "domain/livequery.h" + +#include "utils/jobhandler.h" + +#include "testlib/fakejob.h" + +using namespace Domain; + +typedef QSharedPointer QObjectPtr; +static const char objectIdPropName[] = "objectId"; + +class LiveRelationshipQueryTest : public QObject +{ + Q_OBJECT +private: + + QObject *createObject(int id, const QString &name) + { + QObject *obj = new QObject(this); + obj->setObjectName(name); + obj->setProperty(objectIdPropName, id); + return obj; + } + + static bool compareObjectIds(QObject *obj1, QObject *obj2) + { + return obj1->property(objectIdPropName).toInt() == obj2->property(objectIdPropName).toInt(); + } + + static bool isProject(QObject *obj) + { + return obj->objectName().startsWith(QLatin1String("Project")); + } + + static QPair convertToPair(QObject *object) + { + return qMakePair(object->property(objectIdPropName).toInt(), object->objectName()); + } + + static bool representsPair(QObject *object, const QPair &output) { + return object->property(objectIdPropName).toInt() == output.first; + }; + +private slots: + void shouldHaveInitialFetchFunctionAndPredicate() + { + // GIVEN + Domain::LiveRelationshipQuery> query; + query.setFetchFunction([this] (const Domain::LiveQueryInput::AddFunction &add) { + Utils::JobHandler::install(new FakeJob, [this, add] { + add(createObject(0, QStringLiteral("ProjectA"))); + add(createObject(1, QStringLiteral("ItemA"))); + add(createObject(2, QStringLiteral("ParentA"))); + add(createObject(3, QStringLiteral("ProjectB"))); + add(createObject(4, QStringLiteral("ItemB"))); + add(createObject(5, QStringLiteral("ParentB"))); + add(createObject(6, QStringLiteral("ProjectC"))); + add(createObject(7, QStringLiteral("ItemC"))); + add(createObject(8, QStringLiteral("ParentC"))); + }); + }); + query.setConvertFunction(convertToPair); + query.setPredicateFunction(isProject); + query.setCompareFunction(compareObjectIds); + + // WHEN + Domain::QueryResult>::Ptr result = query.result(); + result->data(); + result = query.result(); // Should not cause any problem or wrong data + QVERIFY(result->data().isEmpty()); + + // THEN + QList> expected; + expected << QPair(0, QStringLiteral("ProjectA")) + << QPair(3, QStringLiteral("ProjectB")) + << QPair(6, QStringLiteral("ProjectC")); + QTRY_COMPARE(result->data(), expected); + } + + void shouldFilterOutNullRawPointers() + { + // GIVEN + auto query = Domain::LiveRelationshipQuery(); + query.setFetchFunction([this] (const Domain::LiveQueryInput::AddFunction &add) { + Utils::JobHandler::install(new FakeJob, [this, add] { + add(QStringLiteral("0")); + add(QStringLiteral("1")); + add(QString()); + add(QStringLiteral("a")); + add(QStringLiteral("2")); + }); + }); + query.setConvertFunction([this] (const QString &s) -> QObject* { + bool ok = false; + const int id = s.toInt(&ok); + if (ok) { + auto object = new QObject(this); + object->setProperty("id", id); + return object; + } else { + return Q_NULLPTR; + } + }); + query.setPredicateFunction([] (const QString &s) { + return !s.isEmpty(); + }); + + // WHEN + auto result = query.result(); + result->data(); + result = query.result(); // Should not cause any problem or wrong data + QVERIFY(result->data().isEmpty()); + + // THEN + QTRY_COMPARE(result->data().size(), 3); + QCOMPARE(result->data().at(0)->property("id").toInt(), 0); + QCOMPARE(result->data().at(1)->property("id").toInt(), 1); + QCOMPARE(result->data().at(2)->property("id").toInt(), 2); + } + + void shouldFilterOutNullSharedPointers() + { + // GIVEN + auto query = Domain::LiveRelationshipQuery(); + query.setFetchFunction([this] (const Domain::LiveQueryInput::AddFunction &add) { + Utils::JobHandler::install(new FakeJob, [this, add] { + add(QStringLiteral("0")); + add(QStringLiteral("1")); + add(QString()); + add(QStringLiteral("a")); + add(QStringLiteral("2")); + }); + }); + query.setConvertFunction([this] (const QString &s) { + bool ok = false; + const int id = s.toInt(&ok); + if (ok) { + auto object = QObjectPtr::create(); + object->setProperty("id", id); + return object; + } else { + return QObjectPtr(); + } + }); + query.setPredicateFunction([] (const QString &s) { + return !s.isEmpty(); + }); + + // WHEN + auto result = query.result(); + result->data(); + result = query.result(); // Should not cause any problem or wrong data + QVERIFY(result->data().isEmpty()); + + // THEN + QTRY_COMPARE(result->data().size(), 3); + QCOMPARE(result->data().at(0)->property("id").toInt(), 0); + QCOMPARE(result->data().at(1)->property("id").toInt(), 1); + QCOMPARE(result->data().at(2)->property("id").toInt(), 2); + } + + void shouldDealWithSeveralFetchesProperly() + { + // GIVEN + Domain::LiveRelationshipQuery> query; + query.setFetchFunction([this] (const Domain::LiveRelationshipQuery::AddFunction &add) { + Utils::JobHandler::install(new FakeJob, [this, add] { + add(createObject(0, QStringLiteral("ProjectA"))); + add(createObject(1, QStringLiteral("ItemA"))); + add(createObject(2, QStringLiteral("ParentA"))); + add(createObject(3, QStringLiteral("ProjectB"))); + add(createObject(4, QStringLiteral("ItemB"))); + add(createObject(5, QStringLiteral("ParentB"))); + add(createObject(6, QStringLiteral("ProjectC"))); + add(createObject(7, QStringLiteral("ItemC"))); + add(createObject(8, QStringLiteral("ParentC"))); + }); + }); + query.setConvertFunction(convertToPair); + query.setPredicateFunction(isProject); + + for (int i = 0; i < 2; i++) { + // WHEN * 2 + Domain::QueryResult>::Ptr result = query.result(); + + // THEN * 2 + QVERIFY(result->data().isEmpty()); + QList> expected; + expected << QPair(0, QStringLiteral("ProjectA")) + << QPair(3, QStringLiteral("ProjectB")) + << QPair(6, QStringLiteral("ProjectC")); + QTRY_COMPARE(result->data(), expected); + } + } + + void shouldClearProviderWhenDeleted() + { + // GIVEN + auto query = new Domain::LiveRelationshipQuery>; + query->setFetchFunction([this] (const Domain::LiveRelationshipQuery::AddFunction &add) { + Utils::JobHandler::install(new FakeJob, [this, add] { + add(createObject(0, QStringLiteral("ProjectA"))); + add(createObject(1, QStringLiteral("ItemA"))); + add(createObject(2, QStringLiteral("ParentA"))); + }); + }); + query->setConvertFunction(convertToPair); + query->setPredicateFunction(isProject); + query->setCompareFunction(compareObjectIds); + + Domain::QueryResult>::Ptr result = query->result(); + QTRY_COMPARE(result->data().count(), 1); + + // WHEN + delete query; + + // THEN + QVERIFY(result->data().isEmpty()); + } + + void shouldReactToAdds() + { + // GIVEN + Domain::LiveRelationshipQuery> query; + query.setFetchFunction([this] (const Domain::LiveRelationshipQuery::AddFunction &add) { + Utils::JobHandler::install(new FakeJob, [this, add] { + add(createObject(0, QStringLiteral("ProjectA"))); + add(createObject(1, QStringLiteral("ItemA"))); + add(createObject(2, QStringLiteral("ParentA"))); + }); + }); + query.setConvertFunction(convertToPair); + query.setPredicateFunction(isProject); + query.setCompareFunction(compareObjectIds); + + Domain::QueryResult>::Ptr result = query.result(); + QList> expected{ qMakePair(0, QString::fromLatin1("ProjectA")) }; + QTRY_COMPARE(result->data(), expected); + + // WHEN + query.onAdded(createObject(3, QStringLiteral("ProjectB"))); + query.onAdded(createObject(4, QStringLiteral("ItemB"))); + query.onAdded(createObject(5, QStringLiteral("ParentB"))); + + // THEN + expected << QPair(3, QStringLiteral("ProjectB")); + QCOMPARE(result->data(), expected); + } + + void shouldReactToRemoves() + { + // GIVEN + Domain::LiveRelationshipQuery> query; + query.setFetchFunction([this] (const Domain::LiveRelationshipQuery::AddFunction &add) { + Utils::JobHandler::install(new FakeJob, [this, add] { + add(createObject(0, QStringLiteral("ProjectA"))); + add(createObject(1, QStringLiteral("ItemA"))); + add(createObject(2, QStringLiteral("ParentA"))); + }); + }); + query.setConvertFunction(convertToPair); + query.setPredicateFunction(isProject); + query.setCompareFunction(compareObjectIds); + query.setRepresentsFunction(representsPair); + + Domain::QueryResult>::Ptr result = query.result(); + QList> expected{ qMakePair(0, QString::fromLatin1("ProjectA")) }; + QTRY_COMPARE(result->data(), expected); + + // WHEN + query.setFetchFunction([this] (const Domain::LiveRelationshipQuery::AddFunction &add) { + Utils::JobHandler::install(new FakeJob, [this, add] {}); + }); + + // unrelated remove -> ignore + query.onRemoved(createObject(3, QStringLiteral("ItemB"))); + QTRY_COMPARE(result->data(), expected); + + // remove item -> reset happens + query.onRemoved(createObject(1, QStringLiteral("ItemA"))); + + // THEN + expected.clear(); + QTRY_COMPARE(result->data(), expected); + } + + void shouldReactToChanges() + { + // GIVEN + Domain::LiveRelationshipQuery> query; + query.setFetchFunction([this] (const Domain::LiveRelationshipQuery::AddFunction &add) { + Utils::JobHandler::install(new FakeJob, [this, add] { + add(createObject(0, QStringLiteral("ProjectA"))); + add(createObject(1, QStringLiteral("ItemA"))); + add(createObject(2, QStringLiteral("ParentA"))); + add(createObject(3, QStringLiteral("ProjectB"))); + add(createObject(4, QStringLiteral("ItemB"))); + add(createObject(5, QStringLiteral("ParentB"))); + add(createObject(6, QStringLiteral("ProjectC"))); + add(createObject(7, QStringLiteral("ItemC"))); + add(createObject(8, QStringLiteral("ParentC"))); + }); + }); + query.setConvertFunction(convertToPair); + query.setPredicateFunction(isProject); + query.setCompareFunction(compareObjectIds); + query.setRepresentsFunction(representsPair); + + Domain::QueryResult>::Ptr result = query.result(); + QList> expected{ qMakePair(0, QString::fromLatin1("ProjectA")), + qMakePair(3, QString::fromLatin1("ProjectB")), + qMakePair(6, QString::fromLatin1("ProjectC")) }; + QTRY_COMPARE(result->data(), expected); + + // WHEN + query.setFetchFunction([this] (const Domain::LiveRelationshipQuery::AddFunction &add) { + Utils::JobHandler::install(new FakeJob, [this, add] { + add(createObject(0, QStringLiteral("ProjectA"))); + add(createObject(1, QStringLiteral("ItemA"))); + add(createObject(2, QStringLiteral("ParentA"))); + add(createObject(3, QStringLiteral("ProjectB-Renamed"))); + add(createObject(4, QStringLiteral("ItemB"))); + add(createObject(5, QStringLiteral("ParentB"))); + add(createObject(6, QStringLiteral("ProjectC"))); + add(createObject(7, QStringLiteral("ItemC"))); + add(createObject(8, QStringLiteral("ParentC"))); + }); + }); + query.onChanged(createObject(3, QStringLiteral("whatever"))); + + // THEN + expected[1] = qMakePair(3, QString::fromLatin1("ProjectB-Renamed")); + QTRY_COMPARE(result->data(), expected); + } + + void shouldIgnoreUnrelatedChangesWhenEmpty() + { + // GIVEN + Domain::LiveRelationshipQuery> query; + bool listingDone = false; + query.setFetchFunction([this, &listingDone] (const Domain::LiveRelationshipQuery::AddFunction &add) { + Q_UNUSED(add); + Utils::JobHandler::install(new FakeJob, [&listingDone] { + listingDone = true; + }); + }); + query.setConvertFunction(convertToPair); + query.setPredicateFunction(isProject); + query.setCompareFunction(compareObjectIds); + query.setRepresentsFunction(representsPair); + + Domain::QueryResult>::Ptr result = query.result(); + QTRY_VERIFY(listingDone); + listingDone = false; + QVERIFY(result->data().isEmpty()); + + // WHEN + query.onChanged(createObject(1, QStringLiteral("ProjectA"))); + + // THEN + QTest::qWait(150); + QVERIFY(!listingDone); + QVERIFY(result->data().isEmpty()); + } + + void shouldAddWhenChangesMakeInputSuitableForQuery() + { + // GIVEN + Domain::LiveRelationshipQuery> query; + bool listingDone = false; + query.setFetchFunction([this, &listingDone] (const Domain::LiveRelationshipQuery::AddFunction &add) { + Utils::JobHandler::install(new FakeJob, [this, add, &listingDone] { + add(createObject(1, QStringLiteral("ItemA"))); + add(createObject(2, QStringLiteral("ParentA"))); + listingDone = true; + }); + }); + query.setConvertFunction(convertToPair); + query.setPredicateFunction(isProject); + query.setCompareFunction(compareObjectIds); + query.setRepresentsFunction(representsPair); + + Domain::QueryResult>::Ptr result = query.result(); + QList> expected; + QTRY_VERIFY(listingDone); + QCOMPARE(result->data(), expected); + + // WHEN + query.setFetchFunction([this] (const Domain::LiveRelationshipQuery::AddFunction &add) { + Utils::JobHandler::install(new FakeJob, [this, add] { + add(createObject(1, QStringLiteral("ItemA"))); + add(createObject(2, QStringLiteral("ProjectA"))); // parent promoted to project + }); + }); + query.onChanged(createObject(2, QStringLiteral("whatever"))); + + // Then + expected << qMakePair(2, QStringLiteral("ProjectA")); + QTRY_COMPARE(result->data(), expected); + } + + void shouldEmptyAndFetchAgainOnReset() + { + // GIVEN + bool afterReset = false; + + Domain::LiveRelationshipQuery> query; + query.setFetchFunction([this, &afterReset] (const Domain::LiveRelationshipQuery::AddFunction &add) { + Utils::JobHandler::install(new FakeJob, [this, &afterReset, add] { + add(createObject(0, QStringLiteral("ProjectA"))); + add(createObject(1, QStringLiteral("ItemA"))); + add(createObject(2, QStringLiteral("ParentA"))); + add(createObject(3, QStringLiteral("ProjectB"))); + add(createObject(4, QStringLiteral("ItemB"))); + add(createObject(5, QStringLiteral("ParentB"))); + + if (afterReset) { + add(createObject(6, QStringLiteral("ProjectC"))); + add(createObject(7, QStringLiteral("ItemC"))); + add(createObject(8, QStringLiteral("ParentC"))); + } + }); + }); + query.setConvertFunction(convertToPair); + query.setPredicateFunction([&afterReset] (QObject *object) { + if (afterReset) + return object->objectName().startsWith(QLatin1String("Item")); + else + return object->objectName().startsWith(QLatin1String("Project")); + }); + query.setCompareFunction(compareObjectIds); + + Domain::QueryResult>::Ptr result = query.result(); + int removeHandlerCallCount = 0; + result->addPostRemoveHandler([&removeHandlerCallCount](const QPair &, int) { + removeHandlerCallCount++; + }); + + QTRY_VERIFY(!result->data().isEmpty()); + QCOMPARE(removeHandlerCallCount, 0); + + // WHEN + query.reset(); + afterReset = true; + + // THEN + const QList> expected = { qMakePair(1, QStringLiteral("ItemA")), + qMakePair(4, QStringLiteral("ItemB")), + qMakePair(7, QStringLiteral("ItemC")) }; + QTRY_COMPARE(result->data(), expected); + QCOMPARE(removeHandlerCallCount, 2); + } +}; + +ZANSHIN_TEST_MAIN(LiveRelationshipQueryTest) + +#include "liverelationshipquerytest.moc" diff --git a/tests/units/presentation/workdaypagemodeltest.cpp b/tests/units/presentation/workdaypagemodeltest.cpp --- a/tests/units/presentation/workdaypagemodeltest.cpp +++ b/tests/units/presentation/workdaypagemodeltest.cpp @@ -82,14 +82,24 @@ childTaskProvider->append(childTask11); childTaskProvider->append(childTask12); + // One project + auto project = Domain::Project::Ptr::create(); + project->setName("KDE"); + auto projectProvider = Domain::QueryResultProvider::Ptr::create(); + auto projectResult = Domain::QueryResult::create(projectProvider); + projectProvider->append(project); + Utils::MockObject taskQueriesMock; taskQueriesMock(&Domain::TaskQueries::findWorkdayTopLevel).when().thenReturn(taskResult); taskQueriesMock(&Domain::TaskQueries::findChildren).when(task1).thenReturn(childTaskResult); taskQueriesMock(&Domain::TaskQueries::findChildren).when(task2).thenReturn(Domain::QueryResult::Ptr()); taskQueriesMock(&Domain::TaskQueries::findChildren).when(task3).thenReturn(Domain::QueryResult::Ptr()); taskQueriesMock(&Domain::TaskQueries::findChildren).when(childTask11).thenReturn(Domain::QueryResult::Ptr()); taskQueriesMock(&Domain::TaskQueries::findChildren).when(childTask12).thenReturn(Domain::QueryResult::Ptr()); + taskQueriesMock(&Domain::TaskQueries::findProject).when(task1).thenReturn(Domain::QueryResult::Ptr()); + taskQueriesMock(&Domain::TaskQueries::findProject).when(task2).thenReturn(projectResult); + Utils::MockObject taskRepositoryMock; Presentation::WorkdayPageModel workday(taskQueriesMock.getInstance(), @@ -157,6 +167,9 @@ QCOMPARE(model->data(task3Index, Qt::CheckStateRole).toBool(), task3->isDone()); QCOMPARE(model->data(taskChildTask12Index, Qt::CheckStateRole).toBool(), childTask12->isDone()); + QCOMPARE(model->data(task1Index, Presentation::WorkdayPageModel::ProjectRole).toString(), "Inbox"); + QCOMPARE(model->data(task2Index, Presentation::WorkdayPageModel::ProjectRole).toString(), "Project: KDE"); + // WHEN taskRepositoryMock(&Domain::TaskRepository::update).when(task1).thenReturn(new FakeJob(this)); taskRepositoryMock(&Domain::TaskRepository::update).when(childTask11).thenReturn(new FakeJob(this));