From 27c53f415457ea664a53cea2d42720c6aae22b75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Cie=C5=9Blak?= Date: Mon, 29 Apr 2024 15:33:40 +0200 Subject: [PATCH] feat(StatusQ/SubmodelProxyModel): Exposing roles computed per submodel to the top-level model Closes: #14390 --- storybook/pages/SubmodelProxyModelPage.qml | 234 ++++++++++++------ .../include/StatusQ/submodelproxymodel.h | 14 ++ ui/StatusQ/src/movablemodel.cpp | 8 +- ui/StatusQ/src/submodelproxymodel.cpp | 161 +++++++++++- ui/StatusQ/src/sumaggregator.cpp | 2 +- ui/StatusQ/tests/tst_MovableModel.cpp | 12 +- ui/StatusQ/tests/tst_SubmodelProxyModel.cpp | 206 ++++++++++++++- 7 files changed, 526 insertions(+), 111 deletions(-) diff --git a/storybook/pages/SubmodelProxyModelPage.qml b/storybook/pages/SubmodelProxyModelPage.qml index 2d7b102036..f7a791e850 100644 --- a/storybook/pages/SubmodelProxyModelPage.qml +++ b/storybook/pages/SubmodelProxyModelPage.qml @@ -7,9 +7,24 @@ import Storybook 1.0 import SortFilterProxyModel 0.2 +import StatusQ.Core.Utils 0.1 + 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 SubmodelProxyModel wrapping the tokens model joins the submodels" + + " to the network model. It also provides filtering and sorting via" + + " SFPM (slider and checkbox below). Additionally, SubmodelProxyModel" + + " 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: [ @@ -95,6 +110,25 @@ Item { sourceModel: tokensModel delegateModel: SortFilterProxyModel { + id: delegateRoot + + // properties exposed as roles to the top-level model + readonly property var balancesCountRole: submodel.count + readonly property int sumRole: aggregator.value + + sourceModel: joinModel + + filters: FastExpressionFilter { + expression: balance >= thresholdSlider.value + + expectedRoles: "balance" + } + + sorters: RoleSorter { + roleName: "name" + enabled: sortCheckBox.checked + } + readonly property LeftJoinModel joinModel: LeftJoinModel { leftModel: submodel rightModel: networksModel @@ -102,102 +136,50 @@ Item { joinRole: "chainId" } - sourceModel: joinModel + readonly property SumAggregator aggregator: SumAggregator { + id: aggregator - filters: ExpressionFilter { - expression: balance >= thresholdSlider.value - } - - sorters: RoleSorter { - roleName: "name" - enabled: sortCheckBox.checked + model: delegateRoot + roleName: "balance" } } submodelRoleName: "balances" } + SortFilterProxyModel { + id: sortBySumProxy + + sourceModel: submodelProxyModel + + 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 consuming model don't have to do any transformation - // of the submodels internally because it's handled externally via - // SubmodelProxyModel. ListView { - id: listView - - Layout.fillWidth: true - Layout.fillHeight: true - - reuseItems: true - - ScrollBar.vertical: ScrollBar {} - - clip: true - spacing: 18 - - model: submodelProxyModel - - delegate: ColumnLayout { - id: delegateRoot - - width: ListView.view.width - height: 46 - spacing: 0 - - readonly property var balances: model.balances - - Label { - 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 - } - } - } - } - } - } - - Rectangle { - Layout.preferredWidth: 1 - Layout.fillHeight: true - Layout.rightMargin: 20 - - color: "lightgray" - } - - ListView { - Layout.preferredWidth: 150 + Layout.preferredWidth: 110 + Layout.leftMargin: 10 Layout.fillHeight: true spacing: 20 @@ -236,6 +218,98 @@ Item { } } } + + 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 + // SubmodelProxyModel. + 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 { diff --git a/ui/StatusQ/include/StatusQ/submodelproxymodel.h b/ui/StatusQ/include/StatusQ/submodelproxymodel.h index fbeed211b4..c8c2593e9b 100644 --- a/ui/StatusQ/include/StatusQ/submodelproxymodel.h +++ b/ui/StatusQ/include/StatusQ/submodelproxymodel.h @@ -3,6 +3,8 @@ #include #include +#include + class QQmlComponent; class SubmodelProxyModel : public QIdentityProxyModel @@ -20,6 +22,7 @@ public: QVariant data(const QModelIndex& index, int role) const override; void setSourceModel(QAbstractItemModel* sourceModel) override; + QHash roleNames() const override; QQmlComponent* delegateModel() const; void setDelegateModel(QQmlComponent* delegateModel); @@ -31,6 +34,10 @@ signals: void delegateModelChanged(); void submodelRoleNameChanged(); +private slots: + void onCustomRoleChanged(QObject* source, int role); + void emitAllDataChanged(); + private: void initializeIfReady(); void initialize(); @@ -39,9 +46,16 @@ private: void onDelegateChanged(); QPointer m_delegateModel; + QPointer m_connector; + QString m_submodelRoleName; bool m_initialized = false; bool m_sourceModelDeleted = false; int m_submodelRole = 0; + bool m_dataChangedQueued = false; + + QHash m_roleNames; + QHash m_additionalRolesMap; + int m_additionalRolesOffset = std::numeric_limits::max(); }; diff --git a/ui/StatusQ/src/movablemodel.cpp b/ui/StatusQ/src/movablemodel.cpp index fc9250c3c0..95f3c1ebc8 100644 --- a/ui/StatusQ/src/movablemodel.cpp +++ b/ui/StatusQ/src/movablemodel.cpp @@ -131,8 +131,7 @@ void MovableModel::syncOrder() void MovableModel::syncOrderInternal() { - if (m_sourceModel) - { + if (m_sourceModel) { auto sourceModel = m_sourceModel; disconnect(m_sourceModel, nullptr, this, nullptr); @@ -148,10 +147,9 @@ void MovableModel::syncOrderInternal() } } - m_indexes.clear(); - if (!m_synced) - { + + if (!m_synced) { m_synced = true; emit syncedChanged(); } diff --git a/ui/StatusQ/src/submodelproxymodel.cpp b/ui/StatusQ/src/submodelproxymodel.cpp index d6676263fd..3f62a40187 100644 --- a/ui/StatusQ/src/submodelproxymodel.cpp +++ b/ui/StatusQ/src/submodelproxymodel.cpp @@ -5,6 +5,20 @@ #include #include +#include + +namespace { + constexpr const auto roleSuffix = "Role"; + + void emptyMessageHandler(QtMsgType type, const QMessageLogContext& context, + const QString& msg) + { + Q_UNUSED(type) + Q_UNUSED(context) + Q_UNUSED(msg) + } +} + SubmodelProxyModel::SubmodelProxyModel(QObject* parent) : QIdentityProxyModel{parent} { @@ -54,9 +68,33 @@ QVariant SubmodelProxyModel::data(const QModelIndex &index, int role) const QVariant wrappedInstance = QVariant::fromValue(instance); + if (m_additionalRolesMap.size()) { + QObject* connector = m_connector->createWithInitialProperties( + { { "target", QVariant::fromValue(instance) } }); + connector->setParent(instance); + + connect(connector, SIGNAL(customRoleChanged(QObject*,int)), + this, SLOT(onCustomRoleChanged(QObject*,int))); + } + submodelObj->setProperty(attachementPropertyName, wrappedInstance); - return QVariant::fromValue(wrappedInstance); + return wrappedInstance; + } + + if (role >= m_additionalRolesOffset + && role < m_additionalRolesOffset + m_additionalRolesMap.size()) + { + auto submodel = data(index, m_submodelRole); + + auto submodelObj = submodel.value(); + + if (submodelObj == nullptr) { + qWarning("Submodel must be a QObject-based type!"); + return {}; + } + + return submodelObj->property(m_roleNames[role] + roleSuffix); } return QIdentityProxyModel::data(index, role); @@ -82,6 +120,11 @@ void SubmodelProxyModel::setSourceModel(QAbstractItemModel* model) initializeIfReady(); } +QHash SubmodelProxyModel::roleNames() const +{ + return m_roleNames; +} + QQmlComponent* SubmodelProxyModel::delegateModel() const { return m_delegateModel; @@ -103,6 +146,8 @@ void SubmodelProxyModel::setDelegateModel(QQmlComponent* delegateModel) m_delegateModel = delegateModel; onDelegateChanged(); + + initializeIfReady(); } const QString& SubmodelProxyModel::submodelRoleName() const @@ -126,28 +171,125 @@ void SubmodelProxyModel::setSubmodelRoleName(const QString& sumodelRoleName) initializeIfReady(); } +void SubmodelProxyModel::onCustomRoleChanged(QObject* source, int role) +{ + if (!m_dataChangedQueued) { + m_dataChangedQueued = true; + QMetaObject::invokeMethod(this, "emitAllDataChanged", Qt::QueuedConnection); + } +} + +void SubmodelProxyModel::emitAllDataChanged() +{ + m_dataChangedQueued = false; + auto count = rowCount(); + + if (count == 0) + return; + + QVector roles(m_additionalRolesMap.cbegin(), + m_additionalRolesMap.cend()); + + emit this->dataChanged(index(0, 0), index(count - 1, 0), roles); +} + void SubmodelProxyModel::initializeIfReady() { if (!m_submodelRoleName.isEmpty() && sourceModel() - && !roleNames().empty()) + && !sourceModel()->roleNames().empty() && m_delegateModel) initialize(); } void SubmodelProxyModel::initialize() { - auto roles = roleNames(); - auto keys = roles.keys(m_submodelRoleName.toUtf8()); - auto keysCount = keys.size(); + auto roles = sourceModel()->roleNames(); + auto submodelKeys = roles.keys(m_submodelRoleName.toUtf8()); + auto submodelKeysCount = submodelKeys.size(); - if (keysCount == 1) { - m_initialized = true; - m_submodelRole = keys.first(); - } else if (keysCount == 0){ + if (submodelKeysCount == 1) { + m_submodelRole = submodelKeys.first(); + } else if (submodelKeysCount == 0){ qWarning() << "Submodel role not found!"; + return; } else { qWarning() << "Malformed source model - multiple roles found for given " "submodel role name!"; + return; } + + auto creationContext = m_delegateModel->creationContext(); + auto parentContext = creationContext + ? creationContext : m_delegateModel->engine()->rootContext(); + + QIdentityProxyModel emptyModel; + + auto context = std::make_unique(parentContext); + + // The delegate object is created in order to inspect properties. It may + // be not properly initialized because of e.g. lack of context properties + // containing submodel. To avoid warnings, they are muted by setting empty + // message handler temporarily. + QtMessageHandler originalHandler = qInstallMessageHandler( + emptyMessageHandler); + std::unique_ptr instance(m_delegateModel->create(context.get())); + qInstallMessageHandler(originalHandler); + + const QMetaObject* meta = instance->metaObject(); + + QStringList additionalRoles; + + for (auto i = meta->propertyOffset(); i < meta->propertyCount(); ++i) { + const QLatin1String propertyName(meta->property(i).name()); + + bool isRole = propertyName.endsWith(QLatin1String(roleSuffix)); + + if (!isRole) + continue; + + additionalRoles << propertyName.chopped(qstrlen(roleSuffix)); + } + + const auto keys = roles.keys(); + const auto maxElementIt = std::max_element(keys.begin(), keys.end()); + + Q_ASSERT(maxElementIt != keys.end()); + + auto maxRoleKey = *maxElementIt; + m_additionalRolesOffset = maxRoleKey + 1; + + for (auto& additionalRole : qAsConst(additionalRoles)) { + auto roleKey = ++maxRoleKey; + + roles.insert(roleKey, additionalRole.toUtf8()); + m_additionalRolesMap.insert(additionalRole, roleKey); + } + + m_roleNames = roles; + + QString connectorCode = R"( + import QtQml 2.15 + + Connections { + signal customRoleChanged(source: QtObject, role: int) + )"; + + for (auto& additionalRole : qAsConst(additionalRoles)) { + int role = m_additionalRolesMap[additionalRole]; + + auto upperCaseRole = additionalRole; + upperCaseRole[0] = upperCaseRole[0].toUpper(); + + connectorCode += QString(R"( + function on%1RoleChanged() { customRoleChanged(target, %2) } + )").arg(upperCaseRole).arg(role); + } + + connectorCode += "}"; + + m_connector = new QQmlComponent(m_delegateModel->engine(), m_delegateModel); + m_connector->setData(connectorCode.toUtf8(), {}); + + m_initialized = true; } void SubmodelProxyModel::initRoles() @@ -168,4 +310,3 @@ void SubmodelProxyModel::onDelegateChanged() { m_submodelRole }); } } - diff --git a/ui/StatusQ/src/sumaggregator.cpp b/ui/StatusQ/src/sumaggregator.cpp index 8c52cbb257..9132b81a2a 100644 --- a/ui/StatusQ/src/sumaggregator.cpp +++ b/ui/StatusQ/src/sumaggregator.cpp @@ -14,7 +14,7 @@ QVariant SumAggregator::calculateAggregation() { // Check if m_roleName is part of the roles of the model QHash roles = model()->roleNames(); - if (!roleExists()) { + if (!roleExists() && model()->rowCount()) { qWarning() << "Provided role name does not exist in the current model"; return 0.0; } diff --git a/ui/StatusQ/tests/tst_MovableModel.cpp b/ui/StatusQ/tests/tst_MovableModel.cpp index 38e38f3e87..4c981b6bb4 100644 --- a/ui/StatusQ/tests/tst_MovableModel.cpp +++ b/ui/StatusQ/tests/tst_MovableModel.cpp @@ -756,6 +756,8 @@ private slots: { "name": "A", "subname": "a1" } ])"); + QVERIFY(isSame(&sfpm, expectedSorted)); + QCOMPARE(model.synced(), true); QCOMPARE(signalsSpy.count(), signalsSpySfpm.count()); QVERIFY(indexesTester.compare()); @@ -792,16 +794,6 @@ private slots: model.move(0, 1); - ListModelWrapper expectedSorted(engine, R"([ - { "name": "C", "subname": "c3" }, - { "name": "C", "subname": "c2" }, - { "name": "C", "subname": "c1" }, - { "name": "B", "subname": "b1" }, - { "name": "A", "subname": "a2" }, - { "name": "A", "subname": "a1" } - ])"); - - auto source2 = R"([ { "name": "E", "subname": "a1" }, { "name": "F", "subname": "a2" }, diff --git a/ui/StatusQ/tests/tst_SubmodelProxyModel.cpp b/ui/StatusQ/tests/tst_SubmodelProxyModel.cpp index 5ef5fff83f..41117399f6 100644 --- a/ui/StatusQ/tests/tst_SubmodelProxyModel.cpp +++ b/ui/StatusQ/tests/tst_SubmodelProxyModel.cpp @@ -11,12 +11,25 @@ #include #include + #include +#include +#include class TestSubmodelProxyModel: 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; @@ -104,13 +117,13 @@ private slots: QCOMPARE(model.rowCount(), 1); - QVariant balances = model.data(model.index(0, 0), + QVariant balances1 = model.data(model.index(0, 0), sourceModel.role("balances")); - - QVERIFY(balances.isValid()); + QVERIFY(balances1.isValid()); QVariant balances2 = model.data(model.index(0, 0), sourceModel.role("balances")); + QVERIFY(balances2.isValid()); // SubmodelProxyModel may create proxy objects on demand, then first // call to data(...) returns freshly created object, the next calls @@ -119,7 +132,10 @@ private slots: // 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(), balances.type()); + QCOMPARE(balances2.type(), balances1.type()); + + // Check if the same instance is returned. + QCOMPARE(balances2.value(), balances1.value()); } void usingNonObjectSubmodelRoleTest() { @@ -230,7 +246,7 @@ private slots: QCOMPARE(model.data(model.index(0, 0), 0), {}); } - void settingUndefinedSubmodelRoleNameText() { + void settingUndefinedSubmodelRoleNameTest() { QQmlEngine engine; auto delegate = std::make_unique(&engine); @@ -258,6 +274,186 @@ private slots: QCOMPARE(model.rowCount(), 3); } + + void addingNewRoleToTopLevelModelTest() { + QQmlEngine engine; + auto delegate = std::make_unique(&engine); + + delegate->setData(QByteArrayLiteral(R"( + import QtQml.Models 2.15 + + ListModel { + id: delegateRoot + + property var sub: submodel + + property int extraValue: submodel.rowCount() + readonly property alias extraValueRole: delegateRoot.extraValue + } + )"), QUrl()); + + SubmodelProxyModel model; + + ListModelWrapper sourceModel(engine, R"([ + { "balances": [], "name": "name 1" }, + { "balances": [ { balance: 1 } ], "name": "name 2" }, + { "balances": [], "name": "name 3" } + ])"); + + model.setSourceModel(sourceModel); + model.setDelegateModel(delegate.get()); + model.setSubmodelRoleName(QStringLiteral("balances")); + + ListModelWrapper expected(engine, R"([ + { "balances": [], "name": "name 1", "extraValue": 0 }, + { "balances": [], "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); + + QVariant wrapperVariant = model.data(model.index(0, 0), + roleForName(roles, "balances")); + QObject* wrapper = wrapperVariant.value(); + QVERIFY(wrapper != nullptr); + wrapper->setProperty("extraValue", 42); + + ListModelWrapper expected2(engine, R"([ + { "balances": [], "name": "name 1", "extraValue": 42 }, + { "balances": [], "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 extraValueRole: 0 + } + )"), QUrl()); + + ListModelWrapper sourceModel(engine, R"([ + { "balances": [], "name": "name 1" } + ])"); + + SubmodelProxyModel model; + model.setSourceModel(sourceModel); + model.setDelegateModel(delegate.get()); + model.setSubmodelRoleName(QStringLiteral("balances")); + + QCOMPARE(model.rowCount(), 1); + + auto roles = model.roleNames(); + QCOMPARE(roles.size(), 3); + + ModelSignalsSpy signalsSpy(&model); + + QVariant wrapperVariant = model.data(model.index(0, 0), + roleForName(roles, "balances")); + QObject* wrapper = wrapperVariant.value(); + QVERIFY(wrapper != nullptr); + + // 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. + wrapper->setProperty("extraValueRole", 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 multipleProxiesTest() { + QSKIP("Not implemented yet. The goal is to make the proxy fully " + "non-intrusive what will fix the isse pointed in this test."); + + QQmlEngine engine; + auto delegate1 = std::make_unique(&engine); + + delegate1->setData(QByteArrayLiteral(R"( + import QtQml.Models 2.15 + + ListModel { + readonly property int myProp: 42 + } + )"), QUrl()); + + auto delegate2 = std::make_unique(&engine); + + delegate2->setData(QByteArrayLiteral(R"( + import QtQml.Models 2.15 + + ListModel { + readonly property int myProp: 11 + } + )"), QUrl()); + + ListModelWrapper sourceModel(engine, R"([ + { "balances": [], "name": "name 1" }, + { "balances": [], "name": "name 2" }, + { "balances": [], "name": "name 3" } + ])"); + + SubmodelProxyModel model1; + model1.setSourceModel(sourceModel); + model1.setDelegateModel(delegate1.get()); + model1.setSubmodelRoleName(QStringLiteral("balances")); + + SubmodelProxyModel model2; + model2.setSourceModel(sourceModel); + model2.setDelegateModel(delegate2.get()); + model2.setSubmodelRoleName(QStringLiteral("balances")); + + auto roles = model1.roleNames(); + QCOMPARE(roles.size(), 2); + + QVariant wrapperVariant1 = model1.data(model1.index(0, 0), + roleForName(roles, "balances")); + QObject* wrapper1 = wrapperVariant1.value(); + QCOMPARE(wrapper1->property("myProp"), 42); + + QVariant wrapperVariant2 = model2.data(model2.index(0, 0), + roleForName(roles, "balances")); + QObject* wrapper2 = wrapperVariant2.value(); + QCOMPARE(wrapper2->property("myProp"), 11); + } }; QTEST_MAIN(TestSubmodelProxyModel)