From c2bfc6b8f41bf102f638aeab0cb94f9d65917ccd Mon Sep 17 00:00:00 2001 From: Noelia Date: Thu, 30 Nov 2023 13:50:04 +0100 Subject: [PATCH] feat(SQ/Aggregator): Created utility `Aggregator` - Abstract class created: `Aggregator` - Derived abstract class created: `SingleRoleAggregator` - Derived class created: `SumAggregator` - `Storybook` page created. - Added unit tests. Closes #12685 --- storybook/pages/AggregatorPage.qml | 174 ++++++++++++ ui/StatusQ/CMakeLists.txt | 6 + ui/StatusQ/include/StatusQ/aggregator.h | 39 +++ .../include/StatusQ/singleroleaggregator.h | 27 ++ ui/StatusQ/include/StatusQ/sumaggregator.h | 15 + ui/StatusQ/src/aggregator.cpp | 51 ++++ ui/StatusQ/src/plugin.cpp | 2 + ui/StatusQ/src/singleroleaggregator.cpp | 38 +++ ui/StatusQ/src/sumaggregator.cpp | 39 +++ ui/StatusQ/tests/CMakeLists.txt | 12 + ui/StatusQ/tests/tst_Aggregator.cpp | 148 ++++++++++ ui/StatusQ/tests/tst_SingleRoleAggregator.cpp | 121 ++++++++ ui/StatusQ/tests/tst_SumAggregator.cpp | 258 ++++++++++++++++++ 13 files changed, 930 insertions(+) create mode 100644 storybook/pages/AggregatorPage.qml create mode 100644 ui/StatusQ/include/StatusQ/aggregator.h create mode 100644 ui/StatusQ/include/StatusQ/singleroleaggregator.h create mode 100644 ui/StatusQ/include/StatusQ/sumaggregator.h create mode 100644 ui/StatusQ/src/aggregator.cpp create mode 100644 ui/StatusQ/src/singleroleaggregator.cpp create mode 100644 ui/StatusQ/src/sumaggregator.cpp create mode 100644 ui/StatusQ/tests/tst_Aggregator.cpp create mode 100644 ui/StatusQ/tests/tst_SingleRoleAggregator.cpp create mode 100644 ui/StatusQ/tests/tst_SumAggregator.cpp diff --git a/storybook/pages/AggregatorPage.qml b/storybook/pages/AggregatorPage.qml new file mode 100644 index 0000000000..e46fe14f3d --- /dev/null +++ b/storybook/pages/AggregatorPage.qml @@ -0,0 +1,174 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +import StatusQ 0.1 + +Item { + id: root + + QtObject { + id: d + + readonly property string balanceRoleName: "balance" + property string roleName: balanceRoleName + } + + ListModel { + id: srcModel + + ListElement { + key: "ETH" + + balances: [ + ListElement { chainId: "1"; balance: 3 }, + ListElement { chainId: "2"; balance: 4 }, + ListElement { chainId: "31"; balance: 2 } + ] + } + + ListElement { + key: "SNT" + + balances: [ + ListElement { chainId: "2"; balance: 42 } + ] + } + + ListElement { + key: "DAI" + + balances: [ + ListElement { chainId: "1"; balance: 4 }, + ListElement { chainId: "3"; balance: 9 } + ] + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 40 + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + + border.color: "gray" + + ListView { + anchors.fill: parent + anchors.margins: 10 + + model: srcModel + spacing: 10 + + header: Label { + height: implicitHeight * 2 + text: `Source model (${srcModel.count})` + + font.bold: true + color: "blue" + + verticalAlignment: Text.AlignVCenter + } + + ScrollBar.vertical: ScrollBar {} + + delegate: ColumnLayout { + height: implicitHeight + spacing: 0 + + Label { + Layout.fillWidth: true + Layout.bottomMargin: 4 + + text: `KEY: ${model.key}` + font.bold: true + } + Label { + Layout.fillWidth: true + Layout.bottomMargin: 4 + + text: `Total balances: ${aggregator.value}` + font.bold: true + color: "red" + } + ListView { + Layout.fillWidth: true + Layout.preferredHeight: childrenRect.height + + model: balances + delegate: Label { + height: implicitHeight + width: ListView.view.width + text: `chainID: ${model.chainId}, balance: ${model.balance}` + } + + + } + SumAggregator { + id: aggregator + + model: balances + roleName: d.roleName + } + } + } + } + + RowLayout { + Button { + id: addRows + text: "Add rows" + onClicked: { + srcModel.get(0).balances.append( {"chainId": "1", "balance": Math.random()} ) + srcModel.get(1).balances.append( {"chainId": "22", "balance": Math.random()} ) + srcModel.get(2).balances.append( {"chainId": "34", "balance": Math.random()} ) + } + } + Button { + id: removeRows + text: "Remove rows" + onClicked: { + if(srcModel.get(0).balances.count > 1) + srcModel.get(0).balances.remove(0) + if(srcModel.get(1).balances.count > 1) + srcModel.get(1).balances.remove(0) + if(srcModel.get(2).balances.count > 1) + srcModel.get(2).balances.remove(0) + } + } + Button { + id: resetModel + text: "Reset model" + onClicked: { + srcModel.get(0).balances.clear() + srcModel.get(1).balances.clear() + srcModel.get(2).balances.clear() + } + } + Button { + id: changeData + text: "Change data" + onClicked: { + srcModel.get(0).balances.get(0).balance = Math.random() + srcModel.get(1).balances.get(0).balance = Math.random() + srcModel.get(2).balances.get(0).balance = Math.random() + } + } + Button { + id: changeRoleName + text: "Change role name" + + onClicked: { + if(d.roleName === d.balanceRoleName) + d.roleName = "chainId" + else + d.roleName = d.balanceRoleName + } + } + } + } +} + +// category: Models diff --git a/ui/StatusQ/CMakeLists.txt b/ui/StatusQ/CMakeLists.txt index 363306439c..08f825988a 100644 --- a/ui/StatusQ/CMakeLists.txt +++ b/ui/StatusQ/CMakeLists.txt @@ -98,6 +98,9 @@ add_library(StatusQ SHARED include/StatusQ/statuswindow.h include/StatusQ/stringutilsinternal.h include/StatusQ/submodelproxymodel.h + include/StatusQ/aggregator.h + include/StatusQ/singleroleaggregator.h + include/StatusQ/sumaggregator.h src/QClipboardProxy.cpp src/leftjoinmodel.cpp src/modelutilsinternal.cpp @@ -109,6 +112,9 @@ add_library(StatusQ SHARED src/statuswindow.cpp src/stringutilsinternal.cpp src/submodelproxymodel.cpp + src/aggregator.cpp + src/singleroleaggregator.cpp + src/sumaggregator.cpp # wallet src/wallet/managetokenscontroller.cpp diff --git a/ui/StatusQ/include/StatusQ/aggregator.h b/ui/StatusQ/include/StatusQ/aggregator.h new file mode 100644 index 0000000000..28bd89ee97 --- /dev/null +++ b/ui/StatusQ/include/StatusQ/aggregator.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include + +class QAbstractItemModel; + +class Aggregator : public QObject { + Q_OBJECT + + Q_PROPERTY(QAbstractItemModel* model READ model WRITE setModel NOTIFY modelChanged) + Q_PROPERTY(QVariant value READ value NOTIFY valueChanged) + +public: + explicit Aggregator(QObject *parent = nullptr); + + QAbstractItemModel* model() const; + void setModel(QAbstractItemModel* model); + + QVariant value() const; + +signals: + void modelChanged(); + void valueChanged(); + +protected slots: + virtual QVariant calculateAggregation() = 0; + +protected: + void recalculate(); + virtual bool acceptRoles(const QVector& roles) { return true; }; + +private: + QAbstractItemModel* m_model = nullptr; + QVariant m_value; + + void connectToModel(); +}; diff --git a/ui/StatusQ/include/StatusQ/singleroleaggregator.h b/ui/StatusQ/include/StatusQ/singleroleaggregator.h new file mode 100644 index 0000000000..beaa267cb1 --- /dev/null +++ b/ui/StatusQ/include/StatusQ/singleroleaggregator.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +#include "StatusQ/aggregator.h" + +class SingleRoleAggregator : public Aggregator { + Q_OBJECT + + Q_PROPERTY(QByteArray roleName READ roleName WRITE setRoleName NOTIFY roleNameChanged) + +public: + explicit SingleRoleAggregator(QObject *parent = nullptr); + + const QByteArray& roleName() const; + void setRoleName(const QByteArray &roleName); + +signals: + void roleNameChanged(); + +protected: + bool acceptRoles(const QVector& roles) override; + bool roleExists() const; + +private: + QByteArray m_roleName; +}; diff --git a/ui/StatusQ/include/StatusQ/sumaggregator.h b/ui/StatusQ/include/StatusQ/sumaggregator.h new file mode 100644 index 0000000000..87103efbc2 --- /dev/null +++ b/ui/StatusQ/include/StatusQ/sumaggregator.h @@ -0,0 +1,15 @@ +#pragma once + +#include +#include + +#include "StatusQ/singleroleaggregator.h" + +class SumAggregator : public SingleRoleAggregator { + +public: + explicit SumAggregator(QObject *parent = nullptr); + +protected slots: + QVariant calculateAggregation() override; +}; diff --git a/ui/StatusQ/src/aggregator.cpp b/ui/StatusQ/src/aggregator.cpp new file mode 100644 index 0000000000..ed4de55d3f --- /dev/null +++ b/ui/StatusQ/src/aggregator.cpp @@ -0,0 +1,51 @@ +#include "StatusQ/aggregator.h" +#include + +Aggregator::Aggregator(QObject *parent) + : QObject(parent){ + connect(this, &Aggregator::modelChanged, this, &Aggregator::recalculate); +} + +QAbstractItemModel* Aggregator::model() const { + return m_model; +} + +void Aggregator::setModel(QAbstractItemModel* model) { + if(m_model == model) + return; + + if(m_model) + disconnect(m_model, nullptr, this, nullptr); + + m_model = model; + connectToModel(); + emit modelChanged(); +} + +QVariant Aggregator::value() const { + return m_value; +} + +void Aggregator::recalculate() +{ + auto newValue = calculateAggregation(); + + if (m_value == newValue) + return; + + m_value = newValue; + emit valueChanged(); +} + +void Aggregator:: connectToModel() { + if(m_model) { + connect(m_model, &QAbstractItemModel::rowsInserted, this, &Aggregator::recalculate); + connect(m_model, &QAbstractItemModel::rowsRemoved, this, &Aggregator::recalculate); + connect(m_model, &QAbstractItemModel::modelReset, this, &Aggregator::recalculate); + connect(m_model, &QAbstractItemModel::dataChanged, this, + [this](auto&, auto&, const QVector& roles) { + if (this->acceptRoles(roles)) + this->recalculate(); + }); + } +} diff --git a/ui/StatusQ/src/plugin.cpp b/ui/StatusQ/src/plugin.cpp index 494dcccdc3..dcd73ea9b7 100644 --- a/ui/StatusQ/src/plugin.cpp +++ b/ui/StatusQ/src/plugin.cpp @@ -13,6 +13,7 @@ #include "StatusQ/statuswindow.h" #include "StatusQ/stringutilsinternal.h" #include "StatusQ/submodelproxymodel.h" +#include "StatusQ/sumaggregator.h" #include "wallet/managetokenscontroller.h" @@ -38,6 +39,7 @@ public: qmlRegisterType("StatusQ", 0, 1, "SubmodelProxyModel"); qmlRegisterType("StatusQ", 0, 1, "RoleRename"); qmlRegisterType("StatusQ", 0, 1, "RolesRenamingModel"); + qmlRegisterType("StatusQ", 0, 1, "SumAggregator"); qmlRegisterSingletonType("StatusQ", 0, 1, "QClipboardProxy", &QClipboardProxy::qmlInstance); diff --git a/ui/StatusQ/src/singleroleaggregator.cpp b/ui/StatusQ/src/singleroleaggregator.cpp new file mode 100644 index 0000000000..6403e991ef --- /dev/null +++ b/ui/StatusQ/src/singleroleaggregator.cpp @@ -0,0 +1,38 @@ +#include "StatusQ/singleroleaggregator.h" + +SingleRoleAggregator::SingleRoleAggregator(QObject *parent) + : Aggregator(parent) {} + +const QByteArray& SingleRoleAggregator::roleName() const { + return m_roleName; +} + +void SingleRoleAggregator::setRoleName(const QByteArray &roleName) { + if (m_roleName == roleName) + return; + m_roleName = roleName; + emit roleNameChanged(); + + recalculate(); +} + +bool SingleRoleAggregator::acceptRoles(const QVector& roles) { + QHash roleNames = model()->roleNames(); + for (const int role : roles) { + if (roleNames.contains(role)) { + QString roleName = QString::fromUtf8(roleNames[role]); + + // Check if the role name is equal to the expected one: + if (roleName == m_roleName) { + return true; + } + } + } + + // If we reach this point, none of the roles match m_roleName + return false; +} + +bool SingleRoleAggregator::roleExists() const { + return !model()->roleNames().keys(m_roleName).empty(); +} diff --git a/ui/StatusQ/src/sumaggregator.cpp b/ui/StatusQ/src/sumaggregator.cpp new file mode 100644 index 0000000000..8c52cbb257 --- /dev/null +++ b/ui/StatusQ/src/sumaggregator.cpp @@ -0,0 +1,39 @@ +#include "StatusQ/sumaggregator.h" + +#include + +SumAggregator::SumAggregator(QObject *parent) + : SingleRoleAggregator(parent) { + recalculate(); +} + +QVariant SumAggregator::calculateAggregation() { + // Check if m_model exists and role name is initialized + if (!model() || roleName().isEmpty()) + return 0.0; + + // Check if m_roleName is part of the roles of the model + QHash roles = model()->roleNames(); + if (!roleExists()) { + qWarning() << "Provided role name does not exist in the current model"; + return 0.0; + } + + // Do the aggregation + double total = 0.0; + int rows = model()->rowCount(); + int role = roles.key(roleName()); + + for (int row = 0; row < rows; ++row) { + QModelIndex index = model()->index(row, 0); + QVariant value = model()->data(index, role); + + bool ok; + total += value.toDouble(&ok); + + if (!ok) + qWarning() << "Unsupported type for given role (not convertible to double)"; + } + + return total; +} diff --git a/ui/StatusQ/tests/CMakeLists.txt b/ui/StatusQ/tests/CMakeLists.txt index 65cc17ecf2..c6b5f60ea3 100644 --- a/ui/StatusQ/tests/CMakeLists.txt +++ b/ui/StatusQ/tests/CMakeLists.txt @@ -52,3 +52,15 @@ add_test(NAME LeftJoinModelTest COMMAND LeftJoinModelTest) add_executable(SubmodelProxyModelTest tst_SubmodelProxyModel.cpp) target_link_libraries(SubmodelProxyModelTest PRIVATE Qt5::Qml Qt5::Test StatusQ) add_test(NAME SubmodelProxyModelTest COMMAND SubmodelProxyModelTest) + +add_executable(AggregatorTest tst_Aggregator.cpp) +target_link_libraries(AggregatorTest PRIVATE Qt5::Test StatusQ) +add_test(NAME AggregatorTest COMMAND AggregatorTest) + +add_executable(SingleRoleAggregatorTest tst_SingleRoleAggregator.cpp) +target_link_libraries(SingleRoleAggregatorTest PRIVATE Qt5::Test StatusQ) +add_test(NAME SingleRoleAggregatorTest COMMAND SingleRoleAggregatorTest) + +add_executable(SumAggregatorTest tst_SumAggregator.cpp) +target_link_libraries(SumAggregatorTest PRIVATE Qt5::Test StatusQ) +add_test(NAME SumAggregatorTest COMMAND SumAggregatorTest) diff --git a/ui/StatusQ/tests/tst_Aggregator.cpp b/ui/StatusQ/tests/tst_Aggregator.cpp new file mode 100644 index 0000000000..2d802b7392 --- /dev/null +++ b/ui/StatusQ/tests/tst_Aggregator.cpp @@ -0,0 +1,148 @@ +#include +#include +#include + +#include "StatusQ/aggregator.h" + +namespace { + +// TODO: To be removed once issue #12843 is resolved and we have a testing utils +class TestSourceModel : public QAbstractListModel { + +public: + explicit TestSourceModel(QList> data) + : m_data(std::move(data)) { + m_roles.reserve(m_data.size()); + + for (auto i = 0; i < m_data.size(); i++) + m_roles.insert(i, m_data.at(i).first.toUtf8()); + } + + int rowCount(const QModelIndex& parent) const override { + Q_ASSERT(m_data.size()); + return m_data.first().second.size(); + } + + QVariant data(const QModelIndex& index, int role) const override { + if (!index.isValid() || role < 0 || role >= m_data.size()) + return {}; + + const auto row = index.row(); + + if (role >= m_data.length() || row >= m_data.at(0).second.length()) + return {}; + + return m_data.at(role).second.at(row); + } + + bool insertRows(int row, int count, const QModelIndex &parent = QModelIndex()) override { + beginInsertRows(parent, row, row + count - 1); + m_data.insert(row, QPair()); + endInsertRows(); + return true; + } + + void update(int index, int role, QVariant value) { + Q_ASSERT(role < m_data.size() && index < m_data[role].second.size()); + m_data[role].second[index].setValue(std::move(value)); + + emit dataChanged(this->index(index, 0), this->index(index, 0), { role }); + } + + void remove(int index) { + beginRemoveRows(QModelIndex{}, index, index); + + for (int i = 0; i < m_data.size(); i++) { + auto& roleVariantList = m_data[i].second; + Q_ASSERT(index < roleVariantList.size()); + roleVariantList.removeAt(index); + } + + endRemoveRows(); + } + + QHash roleNames() const override { + return m_roles; + } + +private: + QList> m_data; + QHash m_roles; +}; + +class ChildAggregator : public Aggregator { + Q_OBJECT + +public: + explicit ChildAggregator(QObject *parent = nullptr) {} + +protected slots: + QVariant calculateAggregation() override { + return {counter++}; + } + +private: + int counter = 0; +}; + +} // anonymous namespace + +class TestAggregator : public QObject +{ + Q_OBJECT + +private: + QString m_roleNameWarningText = "Provided role name does not exist in the current model"; + QString m_unsuportedTypeWarningText = "Unsupported type for given role (not convertible to double)"; + +private slots: + void testModel() { + ChildAggregator aggregator; + TestSourceModel sourceModel({ + { "chainId", { "12", "13", "1", "321" }}, + { "balance", { "0.123", "0.0000015", "1.45", "25.45221001" }} + }); + QSignalSpy modelChangedSpy(&aggregator, &Aggregator::modelChanged); + QSignalSpy valueChangedSpy(&aggregator, &Aggregator::valueChanged); + + // Test 1: Real model + aggregator.setModel(&sourceModel); + QCOMPARE(aggregator.model(), &sourceModel); + QCOMPARE(modelChangedSpy.count(), 1); + QCOMPARE(valueChangedSpy.count(), 1); + + // Test 2: Non existing model + aggregator.setModel(nullptr); + QCOMPARE(aggregator.model(), nullptr); + QCOMPARE(modelChangedSpy.count(), 2); + QCOMPARE(valueChangedSpy.count(), 2); + } + + void testCalculateAggregationTrigger() { + ChildAggregator aggregator; + TestSourceModel sourceModel({ + { "chainId", { "12", "13", "1", "321" }}, + { "balance", { 0.123, 1.0, 1.45, 25.45 }} + }); + QSignalSpy valueChangedSpy(&aggregator, &Aggregator::valueChanged); + int valueChangedSpyCount = 0; + + // Test 1 - Initial: + aggregator.setModel(&sourceModel); + valueChangedSpyCount++; + QCOMPARE(valueChangedSpy.count(), valueChangedSpyCount); + + // Test 2 - Delete row: + sourceModel.remove(0); + valueChangedSpyCount++; + QCOMPARE(valueChangedSpy.count(), valueChangedSpyCount); + + // Test 3 - Update value row: + sourceModel.update(2, 1, 26.45); + valueChangedSpyCount++; + QCOMPARE(valueChangedSpy.count(), valueChangedSpyCount); + } +}; + +QTEST_MAIN(TestAggregator) +#include "tst_Aggregator.moc" diff --git a/ui/StatusQ/tests/tst_SingleRoleAggregator.cpp b/ui/StatusQ/tests/tst_SingleRoleAggregator.cpp new file mode 100644 index 0000000000..9de85a87db --- /dev/null +++ b/ui/StatusQ/tests/tst_SingleRoleAggregator.cpp @@ -0,0 +1,121 @@ +#include +#include +#include + +#include "StatusQ/singleroleaggregator.h" + +namespace { + +// TODO: To be removed once issue #12843 is resolved and we have a testing utils +class TestSourceModel : public QAbstractListModel { + +public: + explicit TestSourceModel(QList> data) + : m_data(std::move(data)) { + m_roles.reserve(m_data.size()); + + for (auto i = 0; i < m_data.size(); i++) + m_roles.insert(i, m_data.at(i).first.toUtf8()); + } + + int rowCount(const QModelIndex& parent) const override { + Q_ASSERT(m_data.size()); + return m_data.first().second.size(); + } + + QVariant data(const QModelIndex& index, int role) const override { + if (!index.isValid() || role < 0 || role >= m_data.size()) + return {}; + + const auto row = index.row(); + + if (role >= m_data.length() || row >= m_data.at(0).second.length()) + return {}; + + return m_data.at(role).second.at(row); + } + + bool insertRows(int row, int count, const QModelIndex &parent = QModelIndex()) override { + beginInsertRows(parent, row, row + count - 1); + m_data.insert(row, QPair()); + endInsertRows(); + return true; + } + + void update(int index, int role, QVariant value) { + Q_ASSERT(role < m_data.size() && index < m_data[role].second.size()); + m_data[role].second[index].setValue(std::move(value)); + + emit dataChanged(this->index(index, 0), this->index(index, 0), { role }); + } + + void remove(int index) { + beginRemoveRows(QModelIndex{}, index, index); + + for (int i = 0; i < m_data.size(); i++) { + auto& roleVariantList = m_data[i].second; + Q_ASSERT(index < roleVariantList.size()); + roleVariantList.removeAt(index); + } + + endRemoveRows(); + } + + QHash roleNames() const override { + return m_roles; + } + +private: + QList> m_data; + QHash m_roles; +}; + +class ChildSingleRoleAggregator : public SingleRoleAggregator { + Q_OBJECT + +public: + explicit ChildSingleRoleAggregator(QObject *parent = nullptr) {} + +protected slots: + QVariant calculateAggregation() override { return {}; } +}; + +} // anonymous namespace + +class TestSingleRoleAggregator : public QObject +{ + Q_OBJECT + +private: + QString m_roleNameWarningText = "Provided role name does not exist in the current model"; + +private slots: + + void testRoleName() { + ChildSingleRoleAggregator aggregator; + TestSourceModel sourceModel({ + { "chainId", { "12", "13", "1", "321" }}, + { "balance", { "0.123", "0.0000015", "1.45", "25.45221001" }} + }); + QSignalSpy roleNameSpy(&aggregator, &SingleRoleAggregator::roleNameChanged); + + // Test 1 - Assign role name but model is nullptr + aggregator.setRoleName("TestRole"); + QCOMPARE(aggregator.roleName(), QString("TestRole")); + QCOMPARE(roleNameSpy.count(), 1); + + // Test 2 - New role but doesn't exist in the current model + aggregator.setModel(&sourceModel); + aggregator.setRoleName("TestRole2"); + QCOMPARE(aggregator.roleName(), QString("TestRole2")); + QCOMPARE(roleNameSpy.count(), 2); + + // Test 3 - New role existing in the current model + aggregator.setRoleName("balance"); + QCOMPARE(aggregator.roleName(), QString("balance")); + QCOMPARE(roleNameSpy.count(), 3); + } +}; + +QTEST_MAIN(TestSingleRoleAggregator) +#include "tst_SingleRoleAggregator.moc" diff --git a/ui/StatusQ/tests/tst_SumAggregator.cpp b/ui/StatusQ/tests/tst_SumAggregator.cpp new file mode 100644 index 0000000000..73b025aeb4 --- /dev/null +++ b/ui/StatusQ/tests/tst_SumAggregator.cpp @@ -0,0 +1,258 @@ +#include +#include +#include + +#include "StatusQ/sumaggregator.h" + +namespace { + +// TODO: To be removed once issue #12843 is resolved and we have a testing utils +class TestSourceModel : public QAbstractListModel { + +public: + explicit TestSourceModel(QList> data) + : m_data(std::move(data)) { + m_roles.reserve(m_data.size()); + + for (auto i = 0; i < m_data.size(); i++) + m_roles.insert(i, m_data.at(i).first.toUtf8()); + } + + int rowCount(const QModelIndex& parent) const override { + Q_ASSERT(m_data.size()); + return m_data.first().second.size(); + } + + QVariant data(const QModelIndex& index, int role) const override { + if (!index.isValid() || role < 0 || role >= m_data.size()) + return {}; + + const auto row = index.row(); + + if (role >= m_data.length() || row >= m_data.at(0).second.length()) + return {}; + + return m_data.at(role).second.at(row); + } + + bool insertRows(int row, int count, const QModelIndex &parent = QModelIndex()) override { + beginInsertRows(parent, row, row + count - 1); + m_data.insert(row, QPair()); + endInsertRows(); + return true; + } + + void update(int index, int role, QVariant value) { + Q_ASSERT(role < m_data.size() && index < m_data[role].second.size()); + m_data[role].second[index].setValue(std::move(value)); + + emit dataChanged(this->index(index, 0), this->index(index, 0), { role }); + } + + void remove(int index) { + beginRemoveRows(QModelIndex{}, index, index); + + for (int i = 0; i < m_data.size(); i++) { + auto& roleVariantList = m_data[i].second; + Q_ASSERT(index < roleVariantList.size()); + roleVariantList.removeAt(index); + } + + endRemoveRows(); + } + + QHash roleNames() const override { + return m_roles; + } + +private: + QList> m_data; + QHash m_roles; +}; + +} // anonymous namespace + +class TestSumAggregator : public QObject +{ + Q_OBJECT + +private: + QString m_roleNameWarningText = "Provided role name does not exist in the current model"; + QString m_unsuportedTypeWarningText = "Unsupported type for given role (not convertible to double)"; + +private slots: + void testEmpty() { + SumAggregator aggregator; + QCOMPARE(aggregator.value(), 0.0); + } + + void testModel() { + SumAggregator aggregator; + TestSourceModel sourceModel({ + { "chainId", { "12", "13", "1", "321" }}, + { "balance", { "0.123", "0.0000015", "1.45", "25.45221001" }} + }); + QSignalSpy modelChangedSpy(&aggregator, &Aggregator::modelChanged); + QSignalSpy valueChangedSpy(&aggregator, &Aggregator::valueChanged); + + // Test 1: Real model + aggregator.setModel(&sourceModel); + QCOMPARE(aggregator.model(), &sourceModel); + QCOMPARE(modelChangedSpy.count(), 1); + QCOMPARE(valueChangedSpy.count(), 0); + + // Test 2: Non existing model + aggregator.setModel(nullptr); + QCOMPARE(aggregator.model(), nullptr); + QCOMPARE(modelChangedSpy.count(), 2); + QCOMPARE(valueChangedSpy.count(), 0); + } + + void testRoleName() { + SumAggregator aggregator; + TestSourceModel sourceModel({ + { "chainId", { "12", "13", "1", "321" }}, + { "balance", { "0.123", "0.0000015", "1.45", "25.45221001" }} + }); + QSignalSpy roleNameSpy(&aggregator, &SingleRoleAggregator::roleNameChanged); + QSignalSpy valueChangedSpy(&aggregator, &Aggregator::valueChanged); + + // Test 1 - Assign role name but model is nullptr + aggregator.setRoleName("TestRole"); + QTest::ignoreMessage(QtWarningMsg, + m_roleNameWarningText.toUtf8()); + QCOMPARE(aggregator.roleName(), QString("TestRole")); + QCOMPARE(roleNameSpy.count(), 1); + QCOMPARE(valueChangedSpy.count(), 0); + + // Test 2 - New role but doesn't exist in the current model + aggregator.setModel(&sourceModel); + QTest::ignoreMessage(QtWarningMsg, + m_roleNameWarningText.toUtf8()); + aggregator.setRoleName("TestRole2"); + QCOMPARE(aggregator.roleName(), QString("TestRole2")); + QCOMPARE(roleNameSpy.count(), 2); + QCOMPARE(valueChangedSpy.count(), 0); + + // Test 3 - New role existing in the current model + aggregator.setRoleName("balance"); + QCOMPARE(aggregator.roleName(), QString("balance")); + QCOMPARE(roleNameSpy.count(), 3); + QCOMPARE(valueChangedSpy.count(), 1); + } + + void testStringTypeValue() { + SumAggregator aggregator; + TestSourceModel sourceModel({ + { "chainId", { "12", "13", "1", "321" }}, + { "balance", { "0.123", "1", "1.45", "25.45" }} + }); + QSignalSpy valueChangedSpy(&aggregator, &Aggregator::valueChanged); + int valueChangedSpyCount = 0; + + // Test 1 - Initial: + aggregator.setModel(&sourceModel); + QCOMPARE(valueChangedSpy.count(), valueChangedSpyCount); + aggregator.setRoleName("balance"); + valueChangedSpyCount++; + QCOMPARE(valueChangedSpy.count(), valueChangedSpyCount); + QCOMPARE(aggregator.value().toDouble(), 28.023); + + // Test 2 - Delete row: + sourceModel.remove(0); + valueChangedSpyCount++; + QCOMPARE(valueChangedSpy.count(), valueChangedSpyCount); + QCOMPARE(aggregator.value().toDouble(), 27.9); + + // Test 3 - Update value row: + sourceModel.update(2, 1, "26.45"); + valueChangedSpyCount++; + QCOMPARE(valueChangedSpy.count(), valueChangedSpyCount); + QCOMPARE(aggregator.value().toDouble(), 28.9); + + // Test 4 - Update value row but other role, not `balance`, so it will not trigger a value change: + sourceModel.update(2, 0, "52"); + QCOMPARE(valueChangedSpy.count(), valueChangedSpyCount); + QCOMPARE(aggregator.value().toDouble(), 28.9); + } + + void testFloatTypeValue() { + SumAggregator aggregator; + TestSourceModel sourceModel({ + { "chainId", { "12", "13", "1", "321" }}, + { "balance", { 0.123, 1.0, 1.45, 25.45 }} + }); + QSignalSpy valueChangedSpy(&aggregator, &Aggregator::valueChanged); + int valueChangedSpyCount = 0; + + // Test 1 - Initial: + aggregator.setModel(&sourceModel); + QCOMPARE(valueChangedSpy.count(), valueChangedSpyCount); + aggregator.setRoleName("balance"); + valueChangedSpyCount++; + QCOMPARE(valueChangedSpy.count(), valueChangedSpyCount); + QCOMPARE(aggregator.value().toDouble(), 28.023); + + // Test 2 - Delete row: + sourceModel.remove(0); + valueChangedSpyCount++; + QCOMPARE(valueChangedSpy.count(), valueChangedSpyCount); + QCOMPARE(aggregator.value().toDouble(), 27.9); + + // Test 3 - Update value row: + sourceModel.update(2, 1, 26.45); + valueChangedSpyCount++; + QCOMPARE(valueChangedSpy.count(), valueChangedSpyCount); + QCOMPARE(aggregator.value().toDouble(), 28.9); + + // Test 4 - Update value row but other role, not `balance`, so it will not trigger a value change: + sourceModel.update(2, 0, "52"); + QCOMPARE(valueChangedSpy.count(), valueChangedSpyCount); + QCOMPARE(aggregator.value().toDouble(), 28.9); + } + + void testStringUnsupportedTypeValue() { + SumAggregator aggregator; + TestSourceModel sourceModel({ + { "chainId", { "12", "13" }}, + { "balance", { "aa", "bb" }} + }); + QSignalSpy valueChangedSpy(&aggregator, &Aggregator::valueChanged); + + aggregator.setModel(&sourceModel); + QCOMPARE(valueChangedSpy.count(), 0); + QTest::ignoreMessage(QtWarningMsg, + m_unsuportedTypeWarningText.toUtf8()); + QTest::ignoreMessage(QtWarningMsg, + m_unsuportedTypeWarningText.toUtf8()); + aggregator.setRoleName("balance"); + + // Value didn't change, it was an unsuported type! + QCOMPARE(valueChangedSpy.count(), 0); + QCOMPARE(aggregator.value().toDouble(), 0); + } + + void testUnsupportedTypeValue() { + SumAggregator aggregator; + TestSourceModel sourceModel({ + { "chainId", { "12", "13" }}, + { "balance", { QByteArray(), QByteArray() }} + }); + QSignalSpy valueChangedSpy(&aggregator, &Aggregator::valueChanged); + + aggregator.setModel(&sourceModel); + QCOMPARE(valueChangedSpy.count(), 0); + QTest::ignoreMessage(QtWarningMsg, + m_unsuportedTypeWarningText.toUtf8()); + QTest::ignoreMessage(QtWarningMsg, + m_unsuportedTypeWarningText.toUtf8()); + aggregator.setRoleName("balance"); + + // Value didn't change, it was an unsuported type! + QCOMPARE(valueChangedSpy.count(), 0); + QCOMPARE(aggregator.value().toDouble(), 0); + } +}; + +QTEST_MAIN(TestSumAggregator) +#include "tst_SumAggregator.moc"