From 958dc7c5baa7957089eab6f5395420344b9c61af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Cie=C5=9Blak?= Date: Wed, 29 May 2024 13:06:29 +0200 Subject: [PATCH] chore(ObjectProxyModel): Generalized version of SubmodelProxyModel Closes: #14893 --- storybook/pages/ObjectProxyModelExample1.qml | 343 +++++++++ storybook/pages/ObjectProxyModelExample2.qml | 88 +++ storybook/pages/ObjectProxyModelPage.qml | 39 + ui/StatusQ/CMakeLists.txt | 2 + ui/StatusQ/include/StatusQ/objectproxymodel.h | 85 +++ ui/StatusQ/src/objectproxymodel.cpp | 332 +++++++++ ui/StatusQ/src/plugin.cpp | 2 + ui/StatusQ/tests/CMakeLists.txt | 4 + ui/StatusQ/tests/tst_ObjectProxyModel.cpp | 684 ++++++++++++++++++ 9 files changed, 1579 insertions(+) create mode 100644 storybook/pages/ObjectProxyModelExample1.qml create mode 100644 storybook/pages/ObjectProxyModelExample2.qml create mode 100644 storybook/pages/ObjectProxyModelPage.qml create mode 100644 ui/StatusQ/include/StatusQ/objectproxymodel.h create mode 100644 ui/StatusQ/src/objectproxymodel.cpp create mode 100644 ui/StatusQ/tests/tst_ObjectProxyModel.cpp diff --git a/storybook/pages/ObjectProxyModelExample1.qml b/storybook/pages/ObjectProxyModelExample1.qml new file mode 100644 index 0000000000..84ec8d1e65 --- /dev/null +++ b/storybook/pages/ObjectProxyModelExample1.qml @@ -0,0 +1,343 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +import StatusQ 0.1 +import StatusQ.Core.Utils 0.1 + +import SortFilterProxyModel 0.2 + +Item { + id: root + + readonly property string intro: + "The example uses two source models. The first model contains networks" + + " (id and metadata such as name and color), visible on the left. The" + + " second model contains tokens metadata and their balances per" + + " network in the submodel (network id, balance).\n" + + "The ObjectProxyModel wrapping the tokens model joins the submodels" + + " to the network model. It also provides filtering and sorting via" + + " SFPM (slider and checkbox below). Additionally, ObjectProxyModel" + + " calculates the summary balance and issues it as a role in the" + + " top-level model (via SumAggregator). This sum is then used to" + + " dynamically sort the tokens model.\nClick on balances to increase" + + " the amount." + + readonly property int numberOfTokens: 2000 + + readonly property var colors: [ + "purple", "lightgreen", "red", "blue", "darkgreen" + ] + + function getRandomInt(max) { + return Math.floor(Math.random() * max); + } + + ListModel { + id: networksModel + + ListElement { + chainId: "1" + name: "Mainnet" + color: "purple" + } + ListElement { + chainId: "2" + name: "Optimism" + color: "lightgreen" + } + ListElement { + chainId: "3" + name: "Status" + color: "red" + } + ListElement { + chainId: "4" + name: "Abitrum" + color: "blue" + } + ListElement { + chainId: "5" + name: "Sepolia" + color: "darkgreen" + } + } + + ListModel { + id: tokensModel + + Component.onCompleted: { + // Populate model with given number of tokens containing random + // balances + const numberOfTokens = root.numberOfTokens + const tokens = [] + + const chainIds = [] + + for (let n = 0; n < networksModel.count; n++) + chainIds.push(networksModel.get(n).chainId) + + for (let i = 0; i < numberOfTokens; i++) { + const balances = [] + const numberOfBalances = 1 + getRandomInt(networksModel.count) + const chainIdsCpy = [...chainIds] + + for (let i = 0; i < numberOfBalances; i++) { + const chainId = chainIdsCpy.splice( + getRandomInt(chainIdsCpy.length), 1)[0] + + balances.push({ + chainId: chainId, + balance: 1 + getRandomInt(200) + }) + } + + tokens.push({ name: `Token ${i + 1}`, balances }) + } + + append(tokens) + } + } + + // Proxy model joining networksModel to submodels under "balances" role. + // Additionally submodel is filtered and sorted via SFPM. All roles declared + // as "expectedRoles" are accessible via "model" context property. + ObjectProxyModel { + id: objectProxyModel + + sourceModel: tokensModel + + delegate: SortFilterProxyModel { + id: delegateRoot + + // properties exposed as roles to the top-level model + readonly property var balancesCount: model.balances.count + readonly property int sum: aggregator.value + readonly property SortFilterProxyModel balances: this + + sourceModel: joinModel + + filters: FastExpressionFilter { + expression: balance >= thresholdSlider.value + + expectedRoles: "balance" + } + + sorters: RoleSorter { + roleName: "name" + enabled: sortCheckBox.checked + } + + readonly property LeftJoinModel joinModel: LeftJoinModel { + leftModel: model.balances + rightModel: networksModel + + joinRole: "chainId" + } + + readonly property SumAggregator aggregator: SumAggregator { + id: aggregator + + model: delegateRoot + roleName: "balance" + } + } + + exposedRoles: ["balances", "balancesCount", "sum"] + expectedRoles: ["balances"] + } + + SortFilterProxyModel { + id: sortBySumProxy + + sourceModel: objectProxyModel + + sorters: RoleSorter { + roleName: "sum" + ascendingOrder: false + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 10 + + Label { + Layout.fillWidth: true + wrapMode: Text.Wrap + lineHeight: 1.2 + text: root.intro + } + + MenuSeparator { + Layout.fillWidth: true + } + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + + ListView { + Layout.preferredWidth: 110 + Layout.leftMargin: 10 + Layout.fillHeight: true + + spacing: 20 + + model: networksModel + + delegate: ColumnLayout { + width: ListView.view.width + + Label { + Layout.fillWidth: true + text: model.name + font.bold: true + } + + Rectangle { + Layout.preferredWidth: changeColorButton.width + Layout.preferredHeight: 10 + + color: model.color + } + + Button { + id: changeColorButton + + text: "Change color" + + onClicked: { + const currentIdx = root.colors.indexOf(model.color) + const numberOfColors = root.colors.length + const nextIdx = (currentIdx + 1) % numberOfColors + + networksModel.setProperty(model.index, "color", + root.colors[nextIdx]) + } + } + } + } + + Rectangle { + Layout.preferredWidth: 1 + Layout.fillHeight: true + Layout.rightMargin: 20 + + color: "lightgray" + } + + // ListView consuming model don't have to do any transformation + // of the submodels internally because it's handled externally via + // ObjectProxyModel. + ListView { + id: listView + + Layout.fillWidth: true + Layout.fillHeight: true + + reuseItems: true + + ScrollBar.vertical: ScrollBar {} + + clip: true + spacing: 18 + + model: sortBySumProxy + + delegate: ColumnLayout { + id: delegateRoot + + width: ListView.view.width + height: 46 + spacing: 0 + + readonly property var balances: model.balances + + Label { + id: tokenLabel + + Layout.fillWidth: true + text: model.name + font.bold: true + } + + RowLayout { + spacing: 14 + + Layout.fillWidth: true + + Repeater { + model: delegateRoot.balances + + Rectangle { + width: label.implicitWidth * 1.5 + height: label.implicitHeight * 2 + + color: "transparent" + border.width: 2 + border.color: model.color + + Label { + id: label + + anchors.centerIn: parent + + text: `${model.name} (${model.balance})` + font.pixelSize: 10 + } + + MouseArea { + anchors.fill: parent + + onClicked: { + const item = ModelUtils.getByKey( + tokensModel, "name", tokenLabel.text) + const index = ModelUtils.indexOf( + item.balances, "chainId", model.chainId) + + item.balances.setProperty( + index, "balance", + item.balances.get(index).balance + 1) + } + } + } + } + + Label { + text: model.balancesCount + " / " + model.sum + } + } + } + } + } + + MenuSeparator { + Layout.fillWidth: true + } + + RowLayout { + Label { + text: `Number of tokens: ${listView.count}, minimum balance:` + } + + Slider { + id: thresholdSlider + + from: 0 + to: 201 + stepSize: 1 + } + + Label { + text: thresholdSlider.value + } + + CheckBox { + id: sortCheckBox + + text: "sort networks by name" + } + } + } +} diff --git a/storybook/pages/ObjectProxyModelExample2.qml b/storybook/pages/ObjectProxyModelExample2.qml new file mode 100644 index 0000000000..6d6850e0b0 --- /dev/null +++ b/storybook/pages/ObjectProxyModelExample2.qml @@ -0,0 +1,88 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +import StatusQ 0.1 + +Item { + id: root + + readonly property string intro: + "This example show how to use ObjectProxyModel in order to overwrite" + + " the existing role with a value computed from the original role and" + + " add new role for controlling selection (writable role)." + + ListModel { + id: srcModel + + ListElement { + uid: 1 + name: "ETH" + } + ListElement { + uid: 2 + name: "SNT" + } + ListElement { + uid: 3 + name: "DAI" + } + } + + ObjectProxyModel { + id: objectProxy + + delegate: QtObject { + readonly property string name: "#" + model.name + property bool selected: true + } + + expectedRoles: ["name", "uid"] + exposedRoles: ["name", "selected"] + + sourceModel: srcModel + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 10 + + Label { + Layout.fillWidth: true + wrapMode: Text.Wrap + lineHeight: 1.2 + text: root.intro + } + + MenuSeparator { + Layout.fillWidth: true + } + + Button { + text: "Select all" + + onClicked: { + const count = objectProxy.rowCount() + + for (let i = 0; i < count; i++) + objectProxy.proxyObject(i).selected = true + } + } + + ListView { + Layout.fillWidth: true + Layout.fillHeight: true + + model: objectProxy + + delegate: CheckBox { + text: model.name + checked: model.selected + + onToggled: { + objectProxy.proxyObject(model.index).selected = checked + } + } + } + } +} diff --git a/storybook/pages/ObjectProxyModelPage.qml b/storybook/pages/ObjectProxyModelPage.qml new file mode 100644 index 0000000000..2a0137e19f --- /dev/null +++ b/storybook/pages/ObjectProxyModelPage.qml @@ -0,0 +1,39 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +ColumnLayout { + + TabBar { + id: tabBar + + TabButton { + text: "Example 1" + } + + TabButton { + text: "Example 2" + } + } + + StackLayout { + id: stackLayout + + Layout.fillWidth: true + Layout.fillHeight: true + + currentIndex: tabBar.currentIndex + + ObjectProxyModelExample1 { + Layout.fillWidth: true + Layout.fillHeight: true + } + + ObjectProxyModelExample2 { + Layout.fillWidth: true + Layout.fillHeight: true + } + } +} + +// category: Models diff --git a/ui/StatusQ/CMakeLists.txt b/ui/StatusQ/CMakeLists.txt index 2c67b720c1..7b94d7c93c 100644 --- a/ui/StatusQ/CMakeLists.txt +++ b/ui/StatusQ/CMakeLists.txt @@ -105,6 +105,7 @@ add_library(StatusQ SHARED include/StatusQ/modelsyncedcontainer.h include/StatusQ/modelutilsinternal.h include/StatusQ/movablemodel.h + include/StatusQ/objectproxymodel.h include/StatusQ/permissionutilsinternal.h include/StatusQ/rolesrenamingmodel.h include/StatusQ/rxvalidator.h @@ -130,6 +131,7 @@ add_library(StatusQ SHARED src/modelentry.cpp src/modelutilsinternal.cpp src/movablemodel.cpp + src/objectproxymodel.cpp src/permissionutilsinternal.cpp src/plugin.cpp src/rolesrenamingmodel.cpp diff --git a/ui/StatusQ/include/StatusQ/objectproxymodel.h b/ui/StatusQ/include/StatusQ/objectproxymodel.h new file mode 100644 index 0000000000..d3340a2670 --- /dev/null +++ b/ui/StatusQ/include/StatusQ/objectproxymodel.h @@ -0,0 +1,85 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "modelsyncedcontainer.h" + +class QQmlComponent; +class QQmlEngine; + +class ObjectProxyModel : public QIdentityProxyModel +{ + Q_OBJECT + + Q_PROPERTY(QQmlComponent* delegate READ delegate + WRITE setDelegate NOTIFY delegateChanged) + + Q_PROPERTY(QStringList expectedRoles READ expectedRoles + WRITE setExpectedRoles NOTIFY expectedRolesChanged) + + Q_PROPERTY(QStringList exposedRoles READ exposedRoles + WRITE setExposedRoles NOTIFY exposedRolesChanged) + +public: + explicit ObjectProxyModel(QObject* parent = nullptr); + + QVariant data(const QModelIndex& index, int role) const override; + void setSourceModel(QAbstractItemModel* sourceModel) override; + QHash roleNames() const override; + + QQmlComponent* delegate() const; + void setDelegate(QQmlComponent* delegate); + + void setExpectedRoles(const QStringList& expectedRoles); + const QStringList& expectedRoles() const; + + void setExposedRoles(const QStringList& exposedRoles); + const QStringList& exposedRoles() const; + + Q_INVOKABLE QObject* proxyObject(int index); + const QObject* proxyObject(int index) const; + +signals: + void delegateChanged(); + void expectedRolesChanged(); + void exposedRolesChanged(); + +protected slots: + void resetInternalData(); + +private slots: + void onCustomRoleChanged(); + void emitAllDataChanged(); + +private: + struct Entry { + std::unique_ptr proxy; + QQmlPropertyMap* rowData = nullptr; + QQmlContext* context = nullptr; + }; + + void initRoles(); + void updateRoleNames(); + void updateIndexes(int from, int to); + + QHash findExpectedRoles( + const QHash& roleNames, + const QStringList& expectedRoles); + + QPointer m_delegate; + QHash m_expectedRoleNames; + + bool m_dataChangedQueued = false; + + QStringList m_expectedRoles; + QStringList m_exposedRoles; + + QHash m_roleNames; + QSet m_exposedRolesSet; + + mutable ModelSyncedContainer m_container; +}; diff --git a/ui/StatusQ/src/objectproxymodel.cpp b/ui/StatusQ/src/objectproxymodel.cpp new file mode 100644 index 0000000000..dc7b3411e4 --- /dev/null +++ b/ui/StatusQ/src/objectproxymodel.cpp @@ -0,0 +1,332 @@ +#include "StatusQ/objectproxymodel.h" + +#include +#include +#include +#include +#include + +#include + +ObjectProxyModel::ObjectProxyModel(QObject* parent) + : QIdentityProxyModel{parent} +{ +} + +QVariant ObjectProxyModel::data(const QModelIndex& index, int role) const +{ + if (!checkIndex(index, CheckIndexOption::IndexIsValid)) + return {}; + + if (m_delegate && m_exposedRolesSet.contains(role)) { + const auto proxy = this->proxyObject(index.row()); + return proxy->property(m_roleNames[role]); + } + + return QIdentityProxyModel::data(index, role); +} + +void ObjectProxyModel::setSourceModel(QAbstractItemModel* model) +{ + if (sourceModel() == model) + return; + + if (sourceModel() != nullptr) + sourceModel()->disconnect(this); + + m_container.setModel(model); + + if (model == nullptr) { + QIdentityProxyModel::setSourceModel(nullptr); + return; + } + + connect(model, &QAbstractItemModel::rowsMoved, this, [this] { + updateIndexes(0, m_container.size() - 1); + }); + + connect(model, &QAbstractItemModel::layoutChanged, this, [this] { + updateIndexes(0, m_container.size() - 1); + }); + + connect(model, &QAbstractItemModel::rowsRemoved, this, + [this](const QModelIndex& parent, int first, int /*last*/) { + auto updateLast = m_container.size() - 1; + + if (first <= updateLast) + updateIndexes(first, updateLast); + }); + + connect(model, &QAbstractItemModel::rowsInserted, this, + [this](const QModelIndex& parent, int first, int /*last*/) { + updateIndexes(first, m_container.size() - 1); + }); + + connect(model, &QAbstractItemModel::dataChanged, this, + [this](const QModelIndex& topLeft, const QModelIndex& bottomRight, + const QVector& roles) + { + auto first = topLeft.row(); + auto last = bottomRight.row(); + + auto model = sourceModel(); + + for (auto idx = first; idx <= last; idx++) { + auto rowData = m_container[idx].rowData; + + QHashIterator i(m_expectedRoleNames); + while (i.hasNext()) { + i.next(); + rowData->insert(i.value(), + model->data(model->index(idx, 0), i.key())); + } + } + }); + + // Workaround for QTBUG-57971-- + if (model->roleNames().isEmpty()) + connect(model, &QAbstractItemModel::rowsInserted, + this, &ObjectProxyModel::initRoles); + + QIdentityProxyModel::setSourceModel(model); +} + +QHash ObjectProxyModel::roleNames() const +{ + return m_roleNames.isEmpty() && sourceModel() + ? sourceModel()->roleNames() : m_roleNames;; +} + +QQmlComponent* ObjectProxyModel::delegate() const +{ + return m_delegate; +} + +void ObjectProxyModel::setDelegate(QQmlComponent* delegate) +{ + if (m_delegate == delegate) + return; + + m_delegate = delegate; + emit delegateChanged(); + + emitAllDataChanged(); +} + +void ObjectProxyModel::setExpectedRoles(const QStringList& expectedRoles) +{ + if (m_expectedRoles == expectedRoles) + return; + + bool hasSource = sourceModel() != nullptr; + + if (hasSource) + beginResetModel(); + + m_expectedRoles = expectedRoles; + + if (hasSource) + endResetModel(); + + emit expectedRolesChanged(); +} + +const QStringList& ObjectProxyModel::expectedRoles() const +{ + return m_expectedRoles; +} + +void ObjectProxyModel::setExposedRoles(const QStringList& exposedRoles) +{ + if (m_exposedRoles == exposedRoles) + return; + + bool hasSource = sourceModel() != nullptr; + + if (hasSource) + beginResetModel(); + + m_exposedRoles = exposedRoles; + + if (hasSource) + endResetModel(); + + emit exposedRolesChanged(); +} + +const QStringList& ObjectProxyModel::exposedRoles() const +{ + return m_exposedRoles; +} + +QObject* ObjectProxyModel::proxyObject(int index) +{ + if (index >= m_container.size()) + return nullptr; + + auto& entry = m_container[index]; + + if (entry.proxy) + return entry.proxy.get(); + + auto creationContext = m_delegate->creationContext(); + auto parentContext = creationContext + ? creationContext : m_delegate->engine()->rootContext(); + + auto context = new QQmlContext(parentContext/*, submodelObj*/); + + auto rowData = new QQmlPropertyMap(context); + auto model = sourceModel(); + + QHashIterator i(m_expectedRoleNames); + while (i.hasNext()) { + i.next(); + rowData->insert(i.value(), model->data(model->index(index, 0), i.key())); + } + + rowData->insert("index", index); + context->setContextProperty("model", rowData); + + QObject* instance = m_delegate->create(context); + context->setParent(instance); + + for (auto& exposedRole : m_exposedRoles) { + QQmlProperty prop(instance, exposedRole, m_delegate->engine()); + + prop.connectNotifySignal(const_cast(this), + SLOT(onCustomRoleChanged())); + } + + entry.proxy.reset(instance); + entry.context = context; + entry.rowData = rowData; + + return instance; +} + +const QObject* ObjectProxyModel::proxyObject(int index) const +{ + return const_cast(this)->proxyObject(index); +} + +void ObjectProxyModel::resetInternalData() +{ + QIdentityProxyModel::resetInternalData(); + updateRoleNames(); + + m_dataChangedQueued = false; +} + +void ObjectProxyModel::onCustomRoleChanged() +{ + if (!m_dataChangedQueued) { + m_dataChangedQueued = true; + QMetaObject::invokeMethod(this, "emitAllDataChanged", + Qt::QueuedConnection); + } +} + +void ObjectProxyModel::emitAllDataChanged() +{ + m_dataChangedQueued = false; + auto count = rowCount(); + + if (count == 0) + return; + + QVector roles(m_exposedRolesSet.cbegin(), + m_exposedRolesSet.cend()); + + if (roles.empty()) + return; + + emit this->dataChanged(index(0, 0), index(count - 1, 0), roles); +} + +void ObjectProxyModel::initRoles() +{ + disconnect(sourceModel(), &QAbstractItemModel::rowsInserted, + this, &ObjectProxyModel::initRoles); + + resetInternalData(); +} + +void ObjectProxyModel::updateRoleNames() +{ + m_roleNames.clear(); + + if (sourceModel() == nullptr) + return; + + auto roles = sourceModel()->roleNames(); + + if (roles.empty()) + return; + + m_expectedRoleNames = findExpectedRoles(roles, m_expectedRoles); + + const auto keys = roles.keys(); + const auto maxElementIt = std::max_element(keys.begin(), keys.end()); + + Q_ASSERT(maxElementIt != keys.end()); + + auto maxRoleKey = *maxElementIt; + + for (auto& exposedRole : qAsConst(m_exposedRoles)) { + + auto exposedRoleByteArray = exposedRole.toUtf8(); + auto keys = roles.keys(exposedRoleByteArray); + + if (!keys.empty()) { + auto key = keys.first(); + + m_exposedRolesSet.insert(key); + + continue; + } + + auto newRole = ++maxRoleKey; + + roles.insert(newRole, exposedRoleByteArray); + m_exposedRolesSet.insert(newRole); + } + + m_roleNames = roles; +} + +void ObjectProxyModel::updateIndexes(int from, int to) +{ + for (auto i = from; i <= to; i++) { + auto& entry = m_container[i]; + + if (entry.proxy) + entry.rowData->insert("index", i); + } +} + +QHash ObjectProxyModel::findExpectedRoles( + const QHash &roleNames, + const QStringList &expectedRoles) +{ + if (roleNames.empty() || expectedRoles.isEmpty()) + return {}; + + QHash expected; + + for (auto& role : expectedRoles) { + auto expectedKeys = roleNames.keys(role.toUtf8()); + auto expectedKeysCount = expectedKeys.size(); + + if (expectedKeysCount == 1) + expected.insert(expectedKeys.first(), role.toUtf8()); + else if (expectedKeysCount == 0) { + qWarning() << "Expected role not found!"; + } else { + qWarning() << "Malformed source model - multiple roles found for given " + "expected role name!"; + return {}; + } + } + + return expected; +} diff --git a/ui/StatusQ/src/plugin.cpp b/ui/StatusQ/src/plugin.cpp index 23aaa7dafa..3b032acb02 100644 --- a/ui/StatusQ/src/plugin.cpp +++ b/ui/StatusQ/src/plugin.cpp @@ -13,6 +13,7 @@ #include "StatusQ/leftjoinmodel.h" #include "StatusQ/modelutilsinternal.h" #include "StatusQ/movablemodel.h" +#include "StatusQ/objectproxymodel.h" #include "StatusQ/permissionutilsinternal.h" #include "StatusQ/rolesrenamingmodel.h" #include "StatusQ/rxvalidator.h" @@ -54,6 +55,7 @@ public: qmlRegisterType("StatusQ", 0, 1, "FastExpressionSorter"); qmlRegisterType("StatusQ", 0, 1, "UndefinedFilter"); + qmlRegisterType("StatusQ", 0, 1, "ObjectProxyModel"); qmlRegisterType("StatusQ", 0, 1, "LeftJoinModel"); qmlRegisterType("StatusQ", 0, 1, "SubmodelProxyModel"); qmlRegisterType("StatusQ", 0, 1, "RoleRename"); diff --git a/ui/StatusQ/tests/CMakeLists.txt b/ui/StatusQ/tests/CMakeLists.txt index da301f55b3..e244fb5da2 100644 --- a/ui/StatusQ/tests/CMakeLists.txt +++ b/ui/StatusQ/tests/CMakeLists.txt @@ -68,6 +68,10 @@ add_executable(SubmodelProxyModelTest tst_SubmodelProxyModel.cpp) target_link_libraries(SubmodelProxyModelTest PRIVATE StatusQ StatusQTestLib) add_test(NAME SubmodelProxyModelTest COMMAND SubmodelProxyModelTest) +add_executable(ObjectProxyModelTest tst_ObjectProxyModel.cpp) +target_link_libraries(ObjectProxyModelTest PRIVATE StatusQ StatusQTestLib) +add_test(NAME ObjectProxyModelTest COMMAND ObjectProxyModelTest) + add_executable(AggregatorTest tst_Aggregator.cpp) target_link_libraries(AggregatorTest PRIVATE StatusQ StatusQTestLib) add_test(NAME AggregatorTest COMMAND AggregatorTest) diff --git a/ui/StatusQ/tests/tst_ObjectProxyModel.cpp b/ui/StatusQ/tests/tst_ObjectProxyModel.cpp new file mode 100644 index 0000000000..bad9cab358 --- /dev/null +++ b/ui/StatusQ/tests/tst_ObjectProxyModel.cpp @@ -0,0 +1,684 @@ +#include +#include + +#include +#include +#include +#include +#include + +#include +#include + +#include + +#include +#include +#include + +class TestObjectProxyModel: public QObject +{ + Q_OBJECT + + int roleForName(const QHash& roles, const QByteArray& name) const + { + auto keys = roles.keys(name); + + if (keys.empty()) + return -1; + + return keys.first(); + } + +private slots: + void basicTest() { + QQmlEngine engine; + QQmlComponent delegate(&engine); + + auto delegateData = R"( + import QtQml 2.15 + QtObject { + readonly property int count: model.balances.count + readonly property QtObject proxyObject: this + } + )"; + + delegate.setData(delegateData, QUrl()); + + ObjectProxyModel model; + + auto source = R"([ + { balances: [ { balance: 4 } ], name: "name 1" }, + { balances: [ { balance: 4 }, {balance: 43} ], name: "name 2" }, + { balances: [], name: "name 3" } + ])"; + + ListModelWrapper sourceModel(engine, source); + + QSignalSpy sourceModelChangedSpy( + &model, &ObjectProxyModel::sourceModelChanged); + QSignalSpy delegateChangedSpy( + &model, &ObjectProxyModel::delegateChanged); + QSignalSpy expectedRolesChangedSpy( + &model, &ObjectProxyModel::expectedRolesChanged); + QSignalSpy exposedRolesChangedSpy( + &model, &ObjectProxyModel::exposedRolesChanged); + + model.setSourceModel(sourceModel); + model.setDelegate(&delegate); + model.setExpectedRoles(QStringList({ QStringLiteral("balances") })); + model.setExposedRoles({ QStringLiteral("proxyObject"), + QStringLiteral("count")}); + + QCOMPARE(sourceModelChangedSpy.count(), 1); + QCOMPARE(delegateChangedSpy.count(), 1); + QCOMPARE(expectedRolesChangedSpy.count(), 1); + + QCOMPARE(model.sourceModel(), sourceModel); + QCOMPARE(model.delegate(), &delegate); + QCOMPARE(model.expectedRoles(), QStringList({ QStringLiteral("balances") })); + QCOMPARE(model.exposedRoles(), QStringList({ QStringLiteral("proxyObject"), + QStringLiteral("count") })); + + QCOMPARE(model.rowCount(), 3); + + QCOMPARE(model.data(model.index(0, 0), sourceModel.role("name")), "name 1"); + QVERIFY(model.data(model.index(0, 0), + sourceModel.role("balances")).isValid()); + + auto roles = model.roleNames(); + + auto object = model.data(model.index(0, 0), + roleForName(roles, "proxyObject")).value(); + QVERIFY(object); + QCOMPARE(object->property("count"), 1); + QCOMPARE(QQmlEngine::objectOwnership(object), + QQmlEngine::CppOwnership); + } + + void submodelTypeTest() { + QQmlEngine engine; + QQmlComponent delegate(&engine); + + auto delegateData = R"( + import QtQml 2.15 + QtObject { + property var count: model.balances.count + } + )"; + + delegate.setData(delegateData, QUrl()); + + ObjectProxyModel model; + + auto source = R"([ + { balances: [ { balance: 4 } ], name: "name 1" } + ])"; + + ListModelWrapper sourceModel(engine, source); + model.setSourceModel(sourceModel); + model.setDelegate(&delegate); + model.setExpectedRoles({ QStringLiteral("balances") }); + + QCOMPARE(model.rowCount(), 1); + + QVariant balances1 = model.data(model.index(0, 0), + sourceModel.role("balances")); + QVERIFY(balances1.isValid()); + + QVariant balances2 = model.data(model.index(0, 0), + sourceModel.role("balances")); + QVERIFY(balances2.isValid()); + + // ObjectProxyModel may create proxy objects on demand, then first + // call to data(...) returns freshly created object, the next calls + // related to the same row should return cached object. It's important + // to have QVariant type identical in both cases. E.g. returning raw + // pointer in first call and pointer wrapped into QPointer in the next + // one leads to problems in UI components in some scenarios even if + // those QVariant types are automatically convertible. + QCOMPARE(balances2.type(), balances1.type()); + + // Check if the same instance is returned. + QCOMPARE(balances2.value(), balances1.value()); + } + + void signalsDisconnectionTest() { + struct Model : public QIdentityProxyModel + { + using QObject::receivers; + }; + + Model sourceModel1, sourceModel2; + ObjectProxyModel model; + + auto signal = SIGNAL(dataChanged(const QModelIndex&, + const QModelIndex&, + const QVector)); + + model.setSourceModel(&sourceModel1); + QVERIFY(sourceModel1.receivers(signal) > 0); + + model.setSourceModel(nullptr); + QVERIFY(sourceModel1.receivers(signal) == 0); + + model.setSourceModel(&sourceModel1); + QVERIFY(sourceModel1.receivers(signal) > 0); + + model.setSourceModel(&sourceModel2); + QVERIFY(sourceModel1.receivers(signal) == 0); + } + + void deletingDelegateTest() { + QQmlEngine engine; + auto delegate = std::make_unique(&engine); + + delegate->setData(QByteArrayLiteral(R"( + import QtQml 2.15 + + QtObject { + property var sub: model.balances + } + )"), QUrl()); + + ObjectProxyModel model; + ListModelWrapper sourceModel(engine, QJsonArray { + QJsonObject {{ "balances", 11 }, { "name", "name 1" }}, + QJsonObject {{ "balances", 12 }, { "name", "name 2" }}, + QJsonObject {{ "balances", 123}, { "name", "name 3" }} + }); + + model.setSourceModel(sourceModel); + model.setDelegate(delegate.get()); + model.setExpectedRoles({ QStringLiteral("balances") }); + + QSignalSpy delegateChangedSpy(&model, + &ObjectProxyModel::delegateChanged); + QSignalSpy dataChangedSpy( + &model, &ObjectProxyModel::dataChanged); + + delegate.reset(); + + QCOMPARE(delegateChangedSpy.count(), 0); + QCOMPARE(dataChangedSpy.count(), 0); + + QCOMPARE(model.rowCount(), 3); + QCOMPARE(model.data(model.index(0, 0), + sourceModel.role("balances")), 11); + } + + void deletingSourceModelTest() { + QQmlEngine engine; + QQmlComponent delegate(&engine); + + delegate.setData(QByteArrayLiteral(R"( + import QtQml 2.15 + + QtObject { + property var sub: model.balances + } + )"), QUrl()); + + ObjectProxyModel model; + + auto sourceModel = std::make_unique(engine, + QJsonArray { + QJsonObject {{ "balances", 11 }, { "name", "name 1" }}, + QJsonObject {{ "balances", 12 }, { "name", "name 2" }}, + QJsonObject {{ "balances", 123}, { "name", "name 3" }} + } + ); + + model.setSourceModel(sourceModel->model()); + model.setDelegate(&delegate); + model.setExpectedRoles({ QStringLiteral("balances") }); + + sourceModel.reset(); + + QCOMPARE(model.rowCount(), 0); + + QTest::ignoreMessage(QtWarningMsg, QRegularExpression(".*")); + QCOMPARE(model.data(model.index(0, 0), 0), {}); + } + + void settingUndefinedExposedRoleNameTest() { + QQmlEngine engine; + auto delegate = std::make_unique(&engine); + + delegate->setData(QByteArrayLiteral(R"( + import QtQml 2.15 + + QtObject { + property var sub: model.balances + } + )"), QUrl()); + + ObjectProxyModel model; + ListModelWrapper sourceModel(engine, QJsonArray { + QJsonObject {{ "balances", 11 }, { "name", "name 1" }}, + QJsonObject {{ "balances", 12 }, { "name", "name 2" }}, + QJsonObject {{ "balances", 123}, { "name", "name 3" }} + }); + + model.setSourceModel(sourceModel); + model.setDelegate(delegate.get()); + + QTest::ignoreMessage(QtWarningMsg, "Expected role not found!"); + + model.setExpectedRoles({ QStringLiteral("undefined") }); + + QCOMPARE(model.rowCount(), 3); + } + + void addingNewRoleToTopLevelModelTest() { + QQmlEngine engine; + auto delegate = std::make_unique(&engine); + + delegate->setData(QByteArrayLiteral(R"( + import QtQml.Models 2.15 + + ListModel { + property int extraValue: model.balances.count + } + )"), QUrl()); + + ObjectProxyModel model; + + ListModelWrapper sourceModel(engine, R"([ + { "balances": [], "name": "name 1" }, + { "balances": [ { balance: 1 } ], "name": "name 2" }, + { "balances": [], "name": "name 3" } + ])"); + + model.setSourceModel(sourceModel); + model.setDelegate(delegate.get()); + + model.setExpectedRoles({ QStringLiteral("balances") }); + model.setExposedRoles({ QStringLiteral("extraValue") }); + + ListModelWrapper expected(engine, R"([ + { "balances": [], "name": "name 1", "extraValue": 0 }, + { "balances": [{ balance: 1 }], "name": "name 2", "extraValue": 1 }, + { "balances": [], "name": "name 3", "extraValue": 0 } + ])"); + + QCOMPARE(model.rowCount(), 3); + + auto roles = model.roleNames(); + QCOMPARE(roles.size(), 3); + QVERIFY(isSame(&model, expected)); + + ModelSignalsSpy signalsSpy(&model); + + + QObject* proxy = model.proxyObject(0); + proxy->setProperty("extraValue", 42); + + ListModelWrapper expected2(engine, R"([ + { "balances": [], "name": "name 1", "extraValue": 42 }, + { "balances": [{ balance: 1 }], "name": "name 2", "extraValue": 1 }, + { "balances": [], "name": "name 3", "extraValue": 0 } + ])"); + + // dataChanged signal emission is scheduled to event loop, not called + // immediately + QCOMPARE(signalsSpy.count(), 0); + + QVERIFY(QTest::qWaitFor([&signalsSpy]() { + return signalsSpy.count() == 1; + })); + + QCOMPARE(signalsSpy.count(), 1); + QCOMPARE(signalsSpy.dataChangedSpy.count(), 1); + + QCOMPARE(signalsSpy.dataChangedSpy.at(0).at(0), model.index(0, 0)); + QCOMPARE(signalsSpy.dataChangedSpy.at(0).at(1), + model.index(model.rowCount() - 1, 0)); + + QVector expectedChangedRoles = { roleForName(roles, "extraValue") }; + QCOMPARE(signalsSpy.dataChangedSpy.at(0).at(2).value>(), + expectedChangedRoles); + + QVERIFY(isSame(&model, expected2)); + } + + void additionalRoleDataChangedWhenEmptyTest() { + QQmlEngine engine; + auto delegate = std::make_unique(&engine); + + delegate->setData(QByteArrayLiteral(R"( + import QtQml.Models 2.15 + + ListModel { + property int extraValue: 0 + } + )"), QUrl()); + + ListModelWrapper sourceModel(engine, R"([ + { "balances": [], "name": "name 1" } + ])"); + + ObjectProxyModel model; + model.setSourceModel(sourceModel); + model.setDelegate(delegate.get()); + model.setExpectedRoles({ QStringLiteral("balances") }); + model.setExposedRoles({ QStringLiteral("extraValue") }); + + QCOMPARE(model.rowCount(), 1); + + auto roles = model.roleNames(); + QCOMPARE(roles.size(), 3); + + ModelSignalsSpy signalsSpy(&model); + + QObject* proxy = model.proxyObject(0); + + + // dataChanged signal emission is scheduled to event loop, not called + // immediately. In the meantime the source may be cleared and then no + // dataChanged event should be emited. + proxy->setProperty("extraValue", 42); + + sourceModel.remove(0); + + QCOMPARE(signalsSpy.count(), 2); + QCOMPARE(signalsSpy.rowsAboutToBeRemovedSpy.count(), 1); + QCOMPARE(signalsSpy.rowsRemovedSpy.count(), 1); + + QTest::qWait(100); + QCOMPARE(signalsSpy.count(), 2); + } + + void modelResetWhenRoleChangedTest() { + QQmlEngine engine; + auto delegateWithRole = std::make_unique(&engine); + + delegateWithRole->setData(QByteArrayLiteral(R"( + import QtQml.Models 2.15 + + ListModel { + property int extraValue: 0 + } + )"), QUrl()); + + auto delegateNoRole = std::make_unique(&engine); + + delegateNoRole->setData(QByteArrayLiteral(R"( + import QtQml.Models 2.15 + + ListModel {} + )"), QUrl()); + + ListModelWrapper sourceModel(engine, R"([ + { "balances": [], "name": "name 1" } + ])"); + + // 1. set source + // 2. set delegate model + // 3. set expected role names + // 4. set exposed role names + { + ObjectProxyModel model; + + ModelSignalsSpy signalsSpy(&model); + + model.setSourceModel(sourceModel); + + QCOMPARE(signalsSpy.count(), 2); + QCOMPARE(signalsSpy.modelAboutToBeResetSpy.count(), 1); + QCOMPARE(signalsSpy.modelResetSpy.count(), 1); + QCOMPARE(model.roleNames().count(), 2); + + model.setDelegate(delegateWithRole.get()); + + QCOMPARE(signalsSpy.count(), 2); + QCOMPARE(signalsSpy.modelAboutToBeResetSpy.count(), 1); + QCOMPARE(signalsSpy.modelResetSpy.count(), 1); + QCOMPARE(model.roleNames().count(), 2); + + model.setExpectedRoles({ QStringLiteral("balances") }); + + QCOMPARE(signalsSpy.count(), 4); + QCOMPARE(signalsSpy.modelAboutToBeResetSpy.count(), 2); + QCOMPARE(signalsSpy.modelResetSpy.count(), 2); + QCOMPARE(model.roleNames().count(), 2); + + + model.setExposedRoles({ QStringLiteral("extraValue") }); + + QCOMPARE(signalsSpy.count(), 6); + QCOMPARE(signalsSpy.modelAboutToBeResetSpy.count(), 3); + QCOMPARE(signalsSpy.modelResetSpy.count(), 3); + QCOMPARE(model.roleNames().count(), 3); + } + + // 1. set delegate model + // 2. set source + // 3. set submodel role name + // 4. set exposed role names + { + ObjectProxyModel model; + + ModelSignalsSpy signalsSpy(&model); + + model.setDelegate(delegateWithRole.get()); + + QCOMPARE(signalsSpy.count(), 0); + QCOMPARE(model.roleNames().count(), 0); + + model.setSourceModel(sourceModel); + + QCOMPARE(signalsSpy.count(), 2); + QCOMPARE(signalsSpy.modelAboutToBeResetSpy.count(), 1); + QCOMPARE(signalsSpy.modelResetSpy.count(), 1); + QCOMPARE(model.roleNames().count(), 2); + + model.setExpectedRoles({ QStringLiteral("balances") }); + + QCOMPARE(signalsSpy.count(), 4); + QCOMPARE(signalsSpy.modelAboutToBeResetSpy.count(), 2); + QCOMPARE(signalsSpy.modelResetSpy.count(), 2); + QCOMPARE(model.roleNames().count(), 2); + + model.setExposedRoles({ QStringLiteral("extraValue") }); + + QCOMPARE(signalsSpy.count(), 6); + QCOMPARE(signalsSpy.modelAboutToBeResetSpy.count(), 3); + QCOMPARE(signalsSpy.modelResetSpy.count(), 3); + QCOMPARE(model.roleNames().count(), 3); + } + + // 1. set submodel role name + // 1. set expected role name + // 2. set delegate model + // 3. set source + { + ObjectProxyModel model; + + ModelSignalsSpy signalsSpy(&model); + + model.setExpectedRoles({ QStringLiteral("balances") }); + model.setExposedRoles({ QStringLiteral("extraValue") }); + + model.setDelegate(delegateWithRole.get()); + + QCOMPARE(signalsSpy.count(), 0); + QCOMPARE(model.roleNames().count(), 0); + + model.setSourceModel(sourceModel); + + QCOMPARE(signalsSpy.count(), 2); + QCOMPARE(signalsSpy.modelAboutToBeResetSpy.count(), 1); + QCOMPARE(signalsSpy.modelResetSpy.count(), 1); + QCOMPARE(model.roleNames().count(), 3); + } + + // 1. set source + // 2. set delegate model (no extra roles) + // 3. set submodel role name + { + ObjectProxyModel model; + + ModelSignalsSpy signalsSpy(&model); + + model.setSourceModel(sourceModel); + + QCOMPARE(signalsSpy.count(), 2); + QCOMPARE(signalsSpy.modelAboutToBeResetSpy.count(), 1); + QCOMPARE(signalsSpy.modelResetSpy.count(), 1); + QCOMPARE(model.roleNames().count(), 2); + + model.setDelegate(delegateNoRole.get()); + + QCOMPARE(signalsSpy.count(), 2); + QCOMPARE(signalsSpy.modelAboutToBeResetSpy.count(), 1); + QCOMPARE(signalsSpy.modelResetSpy.count(), 1); + QCOMPARE(model.roleNames().count(), 2); + + model.setExpectedRoles({ QStringLiteral("balances") }); + + QCOMPARE(signalsSpy.count(), 4); + QCOMPARE(signalsSpy.modelAboutToBeResetSpy.count(), 2); + QCOMPARE(signalsSpy.modelResetSpy.count(), 2); + QCOMPARE(model.roleNames().count(), 2); + } + } + + void sourceModelResetTest() { + class IdentityModel : public QIdentityProxyModel {}; + + QQmlEngine engine; + auto delegate = std::make_unique(&engine); + + delegate->setData(QByteArrayLiteral(R"( + import QtQml.Models 2.15 + + ListModel { + property int extraValueRole: 0 + } + )"), QUrl()); + + ListModelWrapper sourceModel1(engine, R"([ + { "balances": [], "name": "name 1" } + ])"); + + ListModelWrapper sourceModel2(engine, R"([ + { "key": "1", "balances": [], "name": "name 1", "color": "red" } + ])"); + + IdentityModel identity; + identity.setSourceModel(sourceModel1); + + ObjectProxyModel model; + model.setSourceModel(&identity); + model.setDelegate(delegate.get()); + model.setExpectedRoles({ QStringLiteral("balances") }); + model.setExposedRoles({ QStringLiteral("extraValue") }); + + QCOMPARE(model.rowCount(), 1); + auto roles = model.roleNames(); + QCOMPARE(roles.size(), 3); + + ModelSignalsSpy signalsSpy(&model); + + identity.setSourceModel(sourceModel2); + + QCOMPARE(signalsSpy.count(), 2); + QCOMPARE(signalsSpy.modelAboutToBeResetSpy.count(), 1); + QCOMPARE(signalsSpy.modelResetSpy.count(), 1); + + QCOMPARE(model.rowCount(), 1); + roles = model.roleNames(); + QCOMPARE(roles.size(), 5); + } + + void sourceModelLateRolesInitTest() { + QQmlEngine engine; + auto delegate = std::make_unique(&engine); + + delegate->setData(QByteArrayLiteral(R"( + import QtQml.Models 2.15 + + ListModel { + property int extraValue: 0 + } + )"), QUrl()); + + ListModelWrapper sourceModel(engine, R"([])"); + + ObjectProxyModel model; + model.setSourceModel(sourceModel); + model.setDelegate(delegate.get()); + model.setExpectedRoles({ QStringLiteral("balances") }); + model.setExposedRoles({ QStringLiteral("extraValue") }); + + QCOMPARE(model.rowCount(), 0); + auto roles = model.roleNames(); + QCOMPARE(roles.size(), 0); + + ModelSignalsSpy signalsSpy(&model); + + sourceModel.append(QJsonArray { + QJsonObject {{ "name", "D"}, { "balances", "d1" }}, + QJsonObject {{ "name", "D"}, { "balances", "d2" }} + }); + + QCOMPARE(model.rowCount(), 2); + roles = model.roleNames(); + QCOMPARE(roles.size(), 3); + } + + void changingSourceModelTest() { + QQmlEngine engine; + QQmlComponent delegate(&engine); + + auto delegateData = R"( + import QtQml 2.15 + QtObject { + readonly property int count: model.balances.count + } + )"; + + delegate.setData(delegateData, QUrl()); + + ObjectProxyModel model; + + auto source1 = R"([ + { balances: [ { balance: 4 } ], name: "name 1" }, + { balances: [ { balance: 4 }, {balance: 43} ], name: "name 2" } + ])"; + + auto source2 = R"([ + { id: 4, balances: [ { balance: 4 } ], name: "name 1", color: "red" } + ])"; + + ListModelWrapper sourceModel1(engine, source1); + ListModelWrapper sourceModel2(engine, source2); + + model.setSourceModel(sourceModel1); + model.setDelegate(&delegate); + model.setExpectedRoles(QStringList({ QStringLiteral("balances") })); + model.setExposedRoles({ QStringLiteral("count")}); + + QCOMPARE(model.rowCount(), 2); + QCOMPARE(model.roleNames().size(), 3); + + QSignalSpy sourceModelChangedSpy( + &model, &ObjectProxyModel::sourceModelChanged); + + model.setSourceModel(sourceModel2); + + QCOMPARE(sourceModelChangedSpy.count(), 1); + QCOMPARE(model.rowCount(), 1); + QCOMPARE(model.roleNames().size(), 5); + + model.setSourceModel(nullptr); + + QCOMPARE(sourceModelChangedSpy.count(), 2); + QCOMPARE(model.rowCount(), 0); + QCOMPARE(model.roleNames().size(), 0); + } +}; + +QTEST_MAIN(TestObjectProxyModel) +#include "tst_ObjectProxyModel.moc"