From e09d0b090b23bc4d0dc57b2d3e6a9cb438cb5895 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Cie=C5=9Blak?= Date: Tue, 28 Nov 2023 15:32:48 +0100 Subject: [PATCH] feat(StatusQ): GroupingModel for grouping rows with the same key into submodels Closes: #12683 --- storybook/pages/GroupingModelPage.qml | 143 + ui/StatusQ/CMakeLists.txt | 4 +- ui/StatusQ/include/StatusQ/groupingmodel.h | 81 + ui/StatusQ/src/groupingmodel.cpp | 684 +++++ ui/StatusQ/src/plugin.cpp | 4 +- ui/StatusQ/tests/CMakeLists.txt | 6 +- .../src/TestHelpers/listmodelwrapper.cpp | 2 + ui/StatusQ/tests/tst_GroupingModel.cpp | 2514 +++++++++++++++++ 8 files changed, 3435 insertions(+), 3 deletions(-) create mode 100644 storybook/pages/GroupingModelPage.qml create mode 100644 ui/StatusQ/include/StatusQ/groupingmodel.h create mode 100644 ui/StatusQ/src/groupingmodel.cpp create mode 100644 ui/StatusQ/tests/tst_GroupingModel.cpp diff --git a/storybook/pages/GroupingModelPage.qml b/storybook/pages/GroupingModelPage.qml new file mode 100644 index 0000000000..bcb9ead0ca --- /dev/null +++ b/storybook/pages/GroupingModelPage.qml @@ -0,0 +1,143 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +import StatusQ 0.1 + +import SortFilterProxyModel 0.2 + +Pane { + id: root + + ListModel { + id: collectiblesModel + + Component.onCompleted: { + const randomInt = max => Math.floor(Math.random() * max) + const data = [] + + for (let i = 0; i < 1000; i++) { + const collectionName = "collection_" + i + const tokensCount = randomInt(10) + 1 + + for (let j = 0; j < tokensCount; j++) { + data.push({ + collectionName, + tokenName: "token_" + j, + tokenValue: randomInt(200) + }) + } + } + + append(data) + } + } + + SortFilterProxyModel { + id: sfpm + + sourceModel: collectiblesModel + + filters: RangeFilter { + roleName: "tokenValue" + minimumValue: slider.value + } + } + + GroupingModel { + id: groupingModel + + sourceModel: sfpm + groupingRoleName: "collectionName" + submodelRoleName: "collectibles" + } + + ColumnLayout { + anchors.fill: parent + + Label { + Layout.fillWidth: true + Layout.bottomMargin: 10 + + wrapMode: Text.Wrap + text: "Description: flat model with roles 'collectionName'," + + " 'tokenName' and 'tokenValue' is filtered by token value" + + " and grouped by collection name" + } + + Label { + text: "source model count: " + collectiblesModel.count + } + + Label { + text: "filtered model count: " + sfpm.count + } + + Label { + text: "grouped model count: " + listView.count + } + + RowLayout { + Layout.fillHeight: false + + Label { + text: "Token value threshold" + } + + Slider { + id: slider + + stepSize: 1 + value: 100 + from: 0 + to: 200 + } + + Label { + text: slider.value + } + } + + ListView { + id: listView + + Layout.fillWidth: true + Layout.fillHeight: true + + model: groupingModel + spacing: 5 + clip: true + + ScrollBar.vertical: ScrollBar {} + + delegate: RowLayout { + id: delegateRoot + + width: ListView.view.width + + readonly property var collectibles: model.collectibles + + Label { + text: model.collectionName + } + + ListView { + clip: true + orientation: ListView.Horizontal + + Layout.fillWidth: true + Layout.fillHeight: true + + model: delegateRoot.collectibles + + delegate: Label { + text: `${model.tokenName} (val: ${model.tokenValue})` + color: "darkred" + } + } + } + } + } +} + +// category: Models diff --git a/ui/StatusQ/CMakeLists.txt b/ui/StatusQ/CMakeLists.txt index 7b94d7c93c..afd3d50599 100644 --- a/ui/StatusQ/CMakeLists.txt +++ b/ui/StatusQ/CMakeLists.txt @@ -100,6 +100,7 @@ add_library(StatusQ SHARED include/StatusQ/fastexpressionsorter.h include/StatusQ/formatteddoubleproperty.h include/StatusQ/functionaggregator.h + include/StatusQ/groupingmodel.h include/StatusQ/leftjoinmodel.h include/StatusQ/modelentry.h include/StatusQ/modelsyncedcontainer.h @@ -109,9 +110,9 @@ add_library(StatusQ SHARED include/StatusQ/permissionutilsinternal.h include/StatusQ/rolesrenamingmodel.h include/StatusQ/rxvalidator.h + include/StatusQ/singleroleaggregator.h include/StatusQ/snapshotmodel.h include/StatusQ/snapshotobject.h - include/StatusQ/singleroleaggregator.h include/StatusQ/statussyntaxhighlighter.h include/StatusQ/statuswindow.h include/StatusQ/stringutilsinternal.h @@ -127,6 +128,7 @@ add_library(StatusQ SHARED src/fastexpressionsorter.cpp src/formatteddoubleproperty.cpp src/functionaggregator.cpp + src/groupingmodel.cpp src/leftjoinmodel.cpp src/modelentry.cpp src/modelutilsinternal.cpp diff --git a/ui/StatusQ/include/StatusQ/groupingmodel.h b/ui/StatusQ/include/StatusQ/groupingmodel.h new file mode 100644 index 0000000000..db1f53791b --- /dev/null +++ b/ui/StatusQ/include/StatusQ/groupingmodel.h @@ -0,0 +1,81 @@ +#pragma once + +#include + +#include +#include + +class RangeModel; + +class GroupingModel : public QAbstractProxyModel +{ + Q_OBJECT + + Q_PROPERTY(QString groupingRoleName READ groupingRoleName + WRITE setGroupingRoleName NOTIFY groupingRoleNameChanged) + Q_PROPERTY(QString submodelRoleName READ submodelRoleName + WRITE setSubmodelRoleName NOTIFY submodelRoleNameChanged) + +public: + explicit GroupingModel(QObject* parent = nullptr); + ~GroupingModel(); + + void setSourceModel(QAbstractItemModel* sourceModel) override; + + QModelIndex mapToSource(const QModelIndex& proxyIndex) const override; + QModelIndex mapFromSource(const QModelIndex& sourceIndex) const override; + + void setGroupingRoleName(const QString& groupingRoleName); + const QString& groupingRoleName() const; + + void setSubmodelRoleName(const QString& submodelRoleName); + const QString& submodelRoleName() const; + + QVariant data(const QModelIndex &index, int role) const override; + QHash roleNames() const override; + + int columnCount(const QModelIndex& parent = QModelIndex()) const override; + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + + QModelIndex index(int row, int column = 0, const QModelIndex& parent = QModelIndex()) const override; + QModelIndex parent(const QModelIndex& child) const override; + +signals: + void groupingRoleNameChanged(); + void submodelRoleNameChanged(); + +protected slots: + void resetInternalData(); + +private: + static constexpr auto s_defaultSubmodelRoleName = "submodel"; + + struct Entry { + int submodel = 0; // index of submodel + int submodelIndex = 0; // index within submodel + int sourceIndex = 0; // index within source model + }; + + void init(); + void initRoles(); + void initSubmodelRole(); + + void connectSignals(QAbstractItemModel* model); + + std::vector m_entries; + std::vector> m_submodels; + + RangeModel* m_pendingMergeSubmodel = nullptr; + RangeModel* m_pendingRemovalSubmodel = nullptr; + + QHash m_roleNames; + QString m_groupingRoleName; + QString m_submodelRoleName = s_defaultSubmodelRoleName; + + std::optional m_groupingRole; + + int m_submodelRole = -1; + bool m_rolesInitialized = false; + + friend class RangeModel; +}; diff --git a/ui/StatusQ/src/groupingmodel.cpp b/ui/StatusQ/src/groupingmodel.cpp new file mode 100644 index 0000000000..7ac0c9e571 --- /dev/null +++ b/ui/StatusQ/src/groupingmodel.cpp @@ -0,0 +1,684 @@ +#include "StatusQ/groupingmodel.h" + +#include + +#include + +namespace { + +QVariant data(QAbstractItemModel* model, int row, int role) { + return model->data(model->index(row, 0), role); +} + +} // unnamed namespace + + +class RangeModel : public QAbstractListModel +{ + Q_OBJECT + +public: + explicit RangeModel(QAbstractItemModel* sourceModel, + const std::vector& entries, int from, int to) + : m_entries(entries), m_sourceModel(sourceModel), m_from(from), m_to(to) + { + } + + using QAbstractListModel::beginInsertRows; + using QAbstractListModel::endInsertRows; + using QAbstractListModel::beginRemoveRows; + using QAbstractListModel::endRemoveRows; + + QVariant data(const QModelIndex& index, int role) const override + { + if (!index.isValid()) + return {}; + + auto sourceIndex = m_entries[index.row() + m_from].sourceIndex; + + return m_sourceModel->data(m_sourceModel->index(sourceIndex, + index.column()), role); + } + + QHash roleNames() const override + { + return m_sourceModel->roleNames(); + } + + int rowCount(const QModelIndex& parent = {}) const override + { + return m_to - m_from + 1; + } + + int& from() + { + return m_from; + } + + int& to() { + return m_to; + } + + void shift(int offset) + { + m_from += offset; + m_to += offset; + } + +private: + QAbstractItemModel* m_sourceModel = nullptr; + const std::vector& m_entries; + int m_from = 0; + int m_to = 0; +}; + +GroupingModel::GroupingModel(QObject* parent) + : QAbstractProxyModel{parent} +{ +} + +GroupingModel::~GroupingModel() = default; + +void GroupingModel::setSourceModel(QAbstractItemModel* model) +{ + if (sourceModel() == model) + return; + + if (sourceModel() != nullptr) + sourceModel()->disconnect(this); + + beginResetModel(); + + QAbstractProxyModel::setSourceModel(model); + + if (model != nullptr) + connectSignals(model); + + endResetModel(); +} + +QModelIndex GroupingModel::mapToSource(const QModelIndex& proxyIndex) const +{ + if (!sourceModel()) + return {}; + + if (!proxyIndex.isValid()) + return {}; + + if (proxyIndex.model() != sourceModel()) + return {}; + + return index(m_submodels[proxyIndex.row()]->from()); +} + +QModelIndex GroupingModel::mapFromSource(const QModelIndex& sourceIndex) const +{ + if (!sourceIndex.isValid()) + return {}; + + auto& entry = m_entries[sourceIndex.row()]; + auto submodel = entry.submodel; + auto s = m_submodels[submodel].get(); + + return s->index(entry.submodelIndex, 0); +} + +void GroupingModel::setGroupingRoleName(const QString& groupingRoleName) +{ + if (m_groupingRoleName == groupingRoleName) + return; + + m_groupingRoleName = groupingRoleName; + + initSubmodelRole(); + + if (m_groupingRole) + init(); + + emit groupingRoleNameChanged(); +} + +const QString &GroupingModel::groupingRoleName() const +{ + return m_groupingRoleName; +} + +void GroupingModel::setSubmodelRoleName(const QString& submodelRoleName) +{ + if (m_submodelRoleName == submodelRoleName) + return; + + if (!m_roleNames.isEmpty()) + beginResetModel(); + + m_submodelRoleName = submodelRoleName; + + if (!m_roleNames.isEmpty()) + endResetModel(); + + emit submodelRoleNameChanged(); +} + +const QString& GroupingModel::submodelRoleName() const +{ + return m_submodelRoleName; +} + +QVariant GroupingModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid() || index.row() >= m_submodels.size()) + return {}; + + if (role == m_submodelRole) + return QVariant::fromValue(m_submodels[index.row()].get()); + + auto row = index.row(); + + auto destRow = m_submodels[row]->from(); + auto srcRow = m_entries.at(destRow).sourceIndex; + + return sourceModel()->data(sourceModel()->index(srcRow, index.column()), role); +} + +QHash GroupingModel::roleNames() const +{ + return m_roleNames; +} + +int GroupingModel::columnCount(const QModelIndex& parent) const +{ + if (parent.isValid()) + return 0; + + return 1; +} + +int GroupingModel::rowCount(const QModelIndex &parent) const +{ + return m_submodels.size(); +} + +QModelIndex GroupingModel::index(int row, int column, const QModelIndex& parent) const +{ + if (parent.isValid()) + return {}; + + if (row < 0 || column < 0 || row >= rowCount(parent) + || column >= columnCount(parent)) + return {}; + + return createIndex(row, column); +} + +QModelIndex GroupingModel::parent(const QModelIndex& child) const +{ + return {}; +} + +void GroupingModel::resetInternalData() +{ + QAbstractProxyModel::resetInternalData(); + + auto source = sourceModel(); + + m_rolesInitialized = false; + m_roleNames.clear(); + + m_entries.clear(); + m_submodels.clear(); + + if (source == nullptr) + return; + + if (source->rowCount() > 0) { + initRoles(); + initSubmodelRole(); + + if (m_groupingRole) + init(); + } +} + +void GroupingModel::init() +{ + auto count = sourceModel()->rowCount(); + + Q_ASSERT(m_groupingRole.has_value()); + + QVariant previousVal; + + // from/to pair for every group + std::vector> pairs; + + m_entries.reserve(count); + int submodel = -1; + + for (int i = 0; i < count; i++) { + auto val = sourceModel()->data(sourceModel()->index(i, 0), *m_groupingRole); + + if (val != previousVal || pairs.empty()) { + submodel++; + pairs.emplace_back(i, i); + } else { + pairs.back().second++; + } + + m_entries.push_back({submodel, pairs.back().second - pairs.back().first, i}); + + previousVal = val; + } + + m_submodels.reserve(pairs.size()); + std::transform(pairs.cbegin(), pairs.cend(), std::back_inserter(m_submodels), + [this] (auto& entry) { + return std::make_unique(sourceModel(), m_entries, entry.first, entry.second); + }); +} + +void GroupingModel::initRoles() +{ + auto roleNames = sourceModel()->roleNames(); + auto roles = roleNames.keys(); + auto maxIt = std::max_element(roles.cbegin(), roles.cend()); + + m_submodelRole = maxIt == roles.cend() ? 0 : *maxIt + 1; + roleNames.insert(m_submodelRole, m_submodelRoleName.toUtf8()); + + m_roleNames = std::move(roleNames); + m_rolesInitialized = true; +} + +void GroupingModel::initSubmodelRole() +{ + auto groupingRole = m_roleNames.keys(m_groupingRoleName.toUtf8()); + + if (groupingRole.size()) + m_groupingRole = groupingRole.first(); + else + m_groupingRole.reset(); +} + +void GroupingModel::connectSignals(QAbstractItemModel* model) +{ + connect(model, &QAbstractItemModel::rowsInserted, this, + [this, model](const QModelIndex &parent, int first, int last) { + + if (!m_rolesInitialized) { + initRoles(); + initSubmodelRole(); // check order + } + + auto insertCount = last - first + 1; + + std::optional previousGroupingValue; + + if (first - 1 >= 0) + previousGroupingValue = ::data(model, first - 1, *m_groupingRole); + + std::optional nextGroupingValue; + + if (last + 1 < m_entries.size() + insertCount) + nextGroupingValue = ::data(model, last + 1, *m_groupingRole); + + int currentFirst = first; + int currentLast = last; + + int appendToPrevious = 0; + int appendToNext = 0; + + // count data belonging to the previous group + while (previousGroupingValue + && currentFirst <= currentLast + && ::data(model, currentFirst, *m_groupingRole) == *previousGroupingValue) { + currentFirst++; + appendToPrevious++; + } + + // count data belonging to the following group + while (nextGroupingValue + && currentLast >= currentFirst + && ::data(model, currentLast, *m_groupingRole) == *nextGroupingValue) { + currentLast--; + appendToNext++; + } + + int toNewGroups = insertCount - appendToPrevious - appendToNext; + int toRemove = 0; + + // shift indexes to indicate old items for rowsAboutToBe* signals + for (auto i = first; i < m_entries.size(); i++) + m_entries[i].sourceIndex += last - first + 1; + + if (toNewGroups > 0 && previousGroupingValue && nextGroupingValue + && previousGroupingValue == nextGroupingValue) { + + int submodelIndex = m_entries[first - 1].submodel; + RangeModel* submodel = m_submodels[submodelIndex].get(); + + toRemove = submodel->rowCount() - m_entries[first - 1].submodelIndex - 1; + + submodel->beginRemoveRows({}, m_entries[first - 1].submodelIndex + 1, + submodel->rowCount() - 1); + submodel->to() -= toRemove; + submodel->endRemoveRows(); + + toNewGroups += toRemove + appendToNext; + appendToNext = 0; + } + + RangeModel* previousModel = nullptr; + RangeModel* nextModel = nullptr; + + if (appendToPrevious) { + const Entry& entry = m_entries[first - 1]; + int submodel = entry.submodel; + int offset = entry.submodelIndex + 1; + + previousModel = m_submodels[submodel].get(); + previousModel->beginInsertRows({}, offset, appendToPrevious + offset - 1); + } + + // prepare new entries + + QVariant previousVal; + std::vector> pairs; + + int baseline = first + appendToPrevious; + int submodel = -1; + + for (int i = baseline; i < baseline + toNewGroups; i++) { + + Q_ASSERT(m_groupingRole); + + auto val = sourceModel()->data(sourceModel()->index(i, 0), *m_groupingRole); + + if (val != previousVal || pairs.empty()) { + submodel++; + pairs.emplace_back(i, i); + } else { + pairs.back().second++; + } + + previousVal = val; + } + + std::vector> newSubmodels; + + std::transform(pairs.cbegin(), pairs.cend(), std::back_inserter(newSubmodels), + [this] (auto& entry) { + return std::make_unique(sourceModel(), m_entries, entry.first, entry.second); + }); + + if (newSubmodels.size()) { + int offset = first == 0 ? 0 : m_entries[first - 1].submodel + 1; + beginInsertRows({}, offset, offset + newSubmodels.size() - 1); + } + + if (appendToNext) { + Q_ASSERT(first < m_entries.size()); + + int submodel = m_entries[first].submodel; + + Q_ASSERT(submodel < m_submodels.size()); + + nextModel = m_submodels[submodel].get(); + nextModel->beginInsertRows({}, 0, appendToNext - 1); + } + + // ADJUST STRUCTURES + + if (appendToPrevious) { + previousModel->to() += appendToPrevious; + + const Entry& entry = m_entries[first - 1]; + int submodel = entry.submodel; + + for (int i = submodel + 1; i < m_submodels.size(); i++) + m_submodels[i]->shift(appendToPrevious); + } + + if (newSubmodels.size()) { + int offset = first == 0 ? 0 : m_entries[first - 1].submodel + 1; + + m_submodels.insert(m_submodels.begin() + offset, + std::make_move_iterator(newSubmodels.begin()), + std::make_move_iterator(newSubmodels.end())); + + for (int i = offset + newSubmodels.size(); i < m_submodels.size(); i++) + m_submodels[i]->shift(toNewGroups - toRemove); + } + + if (appendToNext) { + nextModel->to() += appendToNext; + + int submodel = m_entries[first].submodel + newSubmodels.size(); + + for (int i = submodel + 1; i < m_submodels.size(); i++) + m_submodels[i]->shift(appendToNext); + } + + m_entries.resize(m_entries.size() + insertCount); + int totalCounter = 0; + + for (std::size_t i = 0; i < m_submodels.size(); i++) { + RangeModel* model = m_submodels[i].get(); + int count = model->rowCount(); + + for (std::size_t j = 0; j < count; j++) { + Entry& entry = m_entries[totalCounter]; + entry.submodel = i; + entry.submodelIndex = j; + entry.sourceIndex = totalCounter; + + totalCounter++; + } + } + + // EMIT SIGNALS + if (previousModel) + previousModel->endInsertRows(); + + if (newSubmodels.size()) + endInsertRows(); + + if (nextModel) { + nextModel->endInsertRows(); + + auto dataChangedIdx = index(m_entries[last].submodel, 0); + auto roles = m_roleNames.keys(); + roles.removeOne(m_submodelRole); + roles.removeOne(*m_groupingRole); + + emit dataChanged(dataChangedIdx, dataChangedIdx, roles.toVector()); + } + }); + + connect(model, &QAbstractItemModel::rowsAboutToBeRemoved, this, + [this, model](const QModelIndex &parent, int first, int last) { + int firstSubmodelToBeRemoved = -1; + int lastSubmodelToBeRemoved = -1; + + bool mergeRequired = first > 0 + && last < m_entries.size() - 1 + && m_entries[first - 1].submodel != m_entries[last + 1].submodel + && ::data(model, first - 1, *m_groupingRole) + == ::data(model, last + 1, *m_groupingRole); + + std::vector> removals; + + for (int i = first; i <= last;) { + Entry& entry = m_entries[i]; + + auto submodel = entry.submodel; + auto submodelIndex = entry.submodelIndex; + + RangeModel* s = m_submodels[submodel].get(); + auto submodelCount = s->rowCount(); + + int remaining = last - i + 1; + + if (submodelIndex == 0 && remaining >= submodelCount) { + if (firstSubmodelToBeRemoved == -1) { + firstSubmodelToBeRemoved = submodel; + lastSubmodelToBeRemoved = submodel; + } else { + lastSubmodelToBeRemoved++; + } + } else { + int removeFrom = submodelIndex; + int removeTo = std::min(removeFrom + remaining, submodelCount) - 1; + int countToRemove = removeTo - removeFrom + 1; + + if (removeFrom == 0) { + if (!mergeRequired) { + s->beginRemoveRows({}, removeFrom, removeTo); + s->from() = s->from() + countToRemove; + s->endRemoveRows(); + } + } else { + s->beginRemoveRows({}, removeFrom, removeTo); + s->to() = s->to() - countToRemove; + + Q_ASSERT(m_pendingRemovalSubmodel == nullptr); + + // removing tail of the submodel + if (removeTo == submodelCount - 1) + s->endRemoveRows(); + // removing from the middle, must be deferred to keep correct + // intermediate state + else + m_pendingRemovalSubmodel = s; + } + } + + i += submodelCount - submodelIndex; + } + + auto mergeCount = 0; + + if (mergeRequired) { + lastSubmodelToBeRemoved++; + + auto submodel = m_entries[first - 1].submodel; + auto submodelToBeMerged = m_entries[last + 1].submodel; + + mergeCount = m_submodels[submodelToBeMerged]->rowCount() + - m_entries[last + 1].submodelIndex; + } + + if (firstSubmodelToBeRemoved != -1) { + beginRemoveRows({}, firstSubmodelToBeRemoved, lastSubmodelToBeRemoved); + + m_submodels.erase(m_submodels.begin() + firstSubmodelToBeRemoved, + m_submodels.begin() + lastSubmodelToBeRemoved + 1); + + endRemoveRows(); + } + + if (mergeRequired) { + auto submodel = m_entries[first - 1].submodel; + + RangeModel* s = m_submodels[submodel].get(); + s->beginInsertRows({}, s->rowCount(), s->rowCount() + mergeCount - 1); + s->to() = s->to() + mergeCount; + + Q_ASSERT(m_pendingMergeSubmodel == nullptr); + m_pendingMergeSubmodel = s; + } + }); + + connect(model, &QAbstractItemModel::rowsRemoved, this, + [this, model](const QModelIndex &parent, int first, int last) { + int newSize = m_entries.size() - (last - first + 1); + m_entries.clear(); + m_entries.reserve(newSize); + + int sourceIndex = 0; + + for (int i = 0; i < m_submodels.size(); i++) { + auto s = m_submodels[i].get(); + auto count = s->rowCount(); + + s->from() = sourceIndex; + s->to() = sourceIndex + count - 1; + + for (int j = 0; j < count; j++) + m_entries.push_back({i, j, sourceIndex++}); + } + + if (m_pendingMergeSubmodel) { + m_pendingMergeSubmodel->endInsertRows(); + m_pendingMergeSubmodel = nullptr; + } + + if (m_pendingRemovalSubmodel) { + m_pendingRemovalSubmodel->endRemoveRows(); + m_pendingRemovalSubmodel = nullptr; + } + + Q_ASSERT(m_entries.size() == newSize); + }); + + connect(model, &QAbstractItemModel::dataChanged, this, [this, model] ( + const QModelIndex& topLeft, const QModelIndex& bottomRight, + const QVector& roles) { + + if (!topLeft.isValid() || !bottomRight.isValid()) + return; + + auto sourceFirst = topLeft.row(); + auto sourceLast = bottomRight.row(); + + auto destFirst = m_entries.at(sourceFirst).submodel; + auto destLast = m_entries.at(sourceLast).submodel; + + // internal models + int changeSize = sourceLast - sourceFirst + 1; + int offset = m_entries.at(sourceFirst).submodelIndex; + + for (auto i = destFirst; i <= destLast; i++) { + auto submodel = m_submodels[i].get(); + auto sumodelChangeSize = std::min(changeSize, + submodel->rowCount() - offset); + + emit submodel->dataChanged(submodel->index(offset), + submodel->index(offset + sumodelChangeSize - 1), + roles); + + changeSize -= sumodelChangeSize; + offset = 0; + } + + // external model + if (m_entries.at(sourceFirst).submodelIndex > 0) + destFirst++; + + if (destLast < destFirst) + return; + + const QVector& rolesAligned = roles.isEmpty() + ? model->roleNames().keys().toVector() + : roles; + + emit this->dataChanged(this->index(destFirst), this->index(destLast), + rolesAligned); + }); + + connect(model, &QAbstractItemModel::modelAboutToBeReset, this, [this] { + this->beginResetModel(); + }); + + connect(model, &QAbstractItemModel::modelReset, this, [this] { + this->endResetModel(); + }); + + connect(model, &QAbstractItemModel::layoutAboutToBeChanged, this, [this] { + this->beginResetModel(); + }); + + connect(model, &QAbstractItemModel::layoutChanged, this, [this] { + this->endResetModel(); + }); +} + +#include "groupingmodel.moc" diff --git a/ui/StatusQ/src/plugin.cpp b/ui/StatusQ/src/plugin.cpp index 3b032acb02..cf7b7c08af 100644 --- a/ui/StatusQ/src/plugin.cpp +++ b/ui/StatusQ/src/plugin.cpp @@ -10,14 +10,15 @@ #include "StatusQ/fastexpressionsorter.h" #include "StatusQ/formatteddoubleproperty.h" #include "StatusQ/functionaggregator.h" +#include "StatusQ/groupingmodel.h" #include "StatusQ/leftjoinmodel.h" +#include "StatusQ/modelentry.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" -#include "StatusQ/modelentry.h" #include "StatusQ/snapshotobject.h" #include "StatusQ/statussyntaxhighlighter.h" #include "StatusQ/statuswindow.h" @@ -46,6 +47,7 @@ public: qmlRegisterType("StatusQ.Models", 0, 1, "ManageTokensController"); qmlRegisterType("StatusQ.Models", 0, 1, "ManageTokensModel"); + qmlRegisterType("StatusQ", 0, 1, "GroupingModel"); qmlRegisterType("StatusQ", 0, 1, "SourceModel"); qmlRegisterType("StatusQ", 0, 1, "ConcatModel"); qmlRegisterType("StatusQ", 0, 1, "MovableModel"); diff --git a/ui/StatusQ/tests/CMakeLists.txt b/ui/StatusQ/tests/CMakeLists.txt index e244fb5da2..9c246597d7 100644 --- a/ui/StatusQ/tests/CMakeLists.txt +++ b/ui/StatusQ/tests/CMakeLists.txt @@ -110,4 +110,8 @@ add_test(NAME ModelEntryTest COMMAND ModelEntryTest) add_executable(SnapshotObjectTest tst_SnapshotObject.cpp) target_link_libraries(SnapshotObjectTest PRIVATE StatusQ StatusQTestLib) -add_test(NAME SnapshotObjectTest COMMAND SnapshotObjectTest) \ No newline at end of file +add_test(NAME SnapshotObjectTest COMMAND SnapshotObjectTest) + +add_executable(GroupingModelTest tst_GroupingModel.cpp) +target_link_libraries(GroupingModelTest PRIVATE Qt5::Qml StatusQ StatusQTestLib) +add_test(NAME GroupingModelTest COMMAND GroupingModelTest) diff --git a/ui/StatusQ/tests/src/TestHelpers/listmodelwrapper.cpp b/ui/StatusQ/tests/src/TestHelpers/listmodelwrapper.cpp index f24001e539..24c6dd26fa 100644 --- a/ui/StatusQ/tests/src/TestHelpers/listmodelwrapper.cpp +++ b/ui/StatusQ/tests/src/TestHelpers/listmodelwrapper.cpp @@ -29,6 +29,8 @@ ListModelWrapper::ListModelWrapper(QQmlEngine& engine, const QString& content) m_model.reset(qobject_cast( component.create(engine.rootContext()))); + + Q_ASSERT_X(m_model, "ListModelWrapper", "creating model failed!"); } ListModelWrapper::ListModelWrapper(QQmlEngine& engine, const QJsonArray& content) diff --git a/ui/StatusQ/tests/tst_GroupingModel.cpp b/ui/StatusQ/tests/tst_GroupingModel.cpp new file mode 100644 index 0000000000..d7bb5552b4 --- /dev/null +++ b/ui/StatusQ/tests/tst_GroupingModel.cpp @@ -0,0 +1,2514 @@ +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include + + +#include +#include +#include + + +class TestGroupingModel: 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(); + } + + static constexpr QModelIndex InvalidIdx{}; + +private slots: + void initializationTest() + { + QQmlEngine engine; + + ListModelWrapper sourceModel(engine, R"([ + { "name": "A", "subname": "a1" }, + { "name": "A", "subname": "a2" }, + { "name": "B", "subname": "b1" }, + { "name": "C", "subname": "c1" }, + { "name": "C", "subname": "c2" }, + { "name": "C", "subname": "c3" }, + { "name": "C", "subname": "c4" }, + { "name": "D", "subname": "d1" }, + { "name": "D", "subname": "d2" }, + { "name": "D", "subname": "d3" }, + { "name": "E", "subname": "e1" }, + { "name": "E", "subname": "e2" } + ])"); + + ListModelWrapper expected(engine, R"([ + { + "name": "A", "subname": "a1", + "submodel": [ + { "name": "A", "subname": "a1" }, + { "name": "A", "subname": "a2" } + ] + }, + { + "name": "B", "subname": "b1", + "submodel": [ + { "name": "B", "subname": "b1" } + ] + }, + { + "name": "C", "subname": "c1", + "submodel": [ + { "name": "C", "subname": "c1" }, + { "name": "C", "subname": "c2" }, + { "name": "C", "subname": "c3" }, + { "name": "C", "subname": "c4" } + ] + }, + { + "name": "D", "subname": "d1", + "submodel": [ + { "name": "D", "subname": "d1" }, + { "name": "D", "subname": "d2" }, + { "name": "D", "subname": "d3" } + ] + }, + { + "name": "E", "subname": "e1", + "submodel": [ + { "name": "E", "subname": "e1" }, + { "name": "E", "subname": "e2" } + ] + } + ])"); + + { + GroupingModel model; + model.setGroupingRoleName("name"); + model.setSourceModel(sourceModel); + + QVERIFY(isSame(model, *expected.model())); + } + + { + GroupingModel model; + model.setSourceModel(sourceModel); + model.setGroupingRoleName("name"); + + QVERIFY(isSame(model, *expected.model())); + } + + { + GroupingModel model; + model.setSourceModel(sourceModel); + model.setGroupingRoleName("name_"); + + QCOMPARE(model.rowCount(), 0); + model.setGroupingRoleName("name"); + + QVERIFY(isSame(model, *expected.model())); + } + } + + void sourceDataChangeTest() + { + QQmlEngine engine; + + ListModelWrapper sourceModel(engine, R"([ + { "name": "A", "subname": "a1" }, // 0 0 + { "name": "A", "subname": "a2" }, // 1 + { "name": "B", "subname": "b1" }, // 1 2 + { "name": "C", "subname": "c1" }, // 2 3 + { "name": "C", "subname": "c2" }, // 4 + { "name": "C", "subname": "c3" }, // 5 + { "name": "C", "subname": "c4" }, // 6 + { "name": "D", "subname": "d1" }, // 3 7 + { "name": "D", "subname": "d2" }, // 8 + { "name": "D", "subname": "d3" }, // 9 + { "name": "E", "subname": "e1" }, // 4 10 + { "name": "E", "subname": "e2" } // 11 + ])"); + + ListModelWrapper expected(engine, R"([ + { + "name": "A", "subname": "a1", + "submodel": [ + { "name": "A", "subname": "a1" }, + { "name": "A", "subname": "a2" } + ] + }, + { + "name": "B", "subname": "b1", + "submodel": [ + { "name": "B", "subname": "b1" } + ] + }, + { + "name": "C", "subname": "c1", + "submodel": [ + { "name": "C", "subname": "c1" }, + { "name": "C", "subname": "c2" }, + { "name": "C", "subname": "c3" }, + { "name": "C", "subname": "c4" } + ] + }, + { + "name": "D", "subname": "d1", + "submodel": [ + { "name": "D", "subname": "d1" }, + { "name": "D", "subname": "d2" }, + { "name": "D", "subname": "d3" } + ] + }, + { + "name": "E", "subname": "e1", + "submodel": [ + { "name": "E", "subname": "e1" }, + { "name": "E", "subname": "e2" } + ] + } + ])"); + + GroupingModel model; + model.setGroupingRoleName("name"); + model.setSourceModel(sourceModel); + + auto roles = model.roleNames(); + auto nameRole = roleForName(roles, "name"); + auto subnameRole = roleForName(roles, "subname"); + auto submodelRole = roleForName(roles, "submodel"); + + auto submodel1 = model.data(model.index(0, 0), submodelRole).value(); + auto submodel2 = model.data(model.index(1, 0), submodelRole).value(); + auto submodel3 = model.data(model.index(2, 0), submodelRole).value(); + auto submodel4 = model.data(model.index(3, 0), submodelRole).value(); + auto submodel5 = model.data(model.index(4, 0), submodelRole).value(); + + { + ModelSignalsSpy signalsSpy(&model); + ModelSignalsSpy submodel1Spy(submodel1); + ModelSignalsSpy submodel2Spy(submodel2); + ModelSignalsSpy submodel3Spy(submodel3); + ModelSignalsSpy submodel4Spy(submodel4); + ModelSignalsSpy submodel5Spy(submodel5); + + emit sourceModel.model()->dataChanged(sourceModel.model()->index(0, 0), + sourceModel.model()->index(0, 0)); + + 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(0, 0)); + + QSet expectedRoles{nameRole, subnameRole}; + auto roles = signalsSpy.dataChangedSpy.at(0).at(2).value>(); + QSet rolesSet(roles.cbegin(), roles.cend()); + + QCOMPARE(rolesSet, expectedRoles); + + QCOMPARE(submodel1Spy.count(), 1); + QCOMPARE(submodel1Spy.dataChangedSpy.count(), 1); + QCOMPARE(submodel1Spy.dataChangedSpy.at(0).at(0), submodel1->index(0, 0)); + QCOMPARE(submodel1Spy.dataChangedSpy.at(0).at(1), submodel1->index(0, 0)); + QCOMPARE(submodel2Spy.count(), 0); + QCOMPARE(submodel3Spy.count(), 0); + QCOMPARE(submodel4Spy.count(), 0); + QCOMPARE(submodel5Spy.count(), 0); + } + { + ModelSignalsSpy signalsSpy(&model); + ModelSignalsSpy submodel1Spy(submodel1); + ModelSignalsSpy submodel2Spy(submodel2); + ModelSignalsSpy submodel3Spy(submodel3); + ModelSignalsSpy submodel4Spy(submodel4); + ModelSignalsSpy submodel5Spy(submodel5); + + emit sourceModel.model()->dataChanged(sourceModel.model()->index(4, 0), + sourceModel.model()->index(7, 0), + { subnameRole }); + + QCOMPARE(signalsSpy.count(), 1); + QCOMPARE(signalsSpy.dataChangedSpy.count(), 1); + QCOMPARE(signalsSpy.dataChangedSpy.at(0).at(0), model.index(3, 0)); + QCOMPARE(signalsSpy.dataChangedSpy.at(0).at(1), model.index(3, 0)); + + QVector expectedRoles{subnameRole}; + auto roles = signalsSpy.dataChangedSpy.at(0).at(2).value>(); + + QCOMPARE(roles, expectedRoles); + + QCOMPARE(submodel1Spy.count(), 0); + QCOMPARE(submodel2Spy.count(), 0); + QCOMPARE(submodel3Spy.count(), 1); + QCOMPARE(submodel3Spy.dataChangedSpy.count(), 1); + QCOMPARE(submodel3Spy.dataChangedSpy.at(0).at(0), submodel3->index(1, 0)); + QCOMPARE(submodel3Spy.dataChangedSpy.at(0).at(1), submodel3->index(3, 0)); + + roles = submodel3Spy.dataChangedSpy.at(0).at(2).value>(); + QCOMPARE(roles, expectedRoles); + + QCOMPARE(submodel4Spy.count(), 1); + QCOMPARE(submodel4Spy.dataChangedSpy.count(), 1); + QCOMPARE(submodel4Spy.dataChangedSpy.at(0).at(0), submodel4->index(0, 0)); + QCOMPARE(submodel4Spy.dataChangedSpy.at(0).at(1), submodel4->index(0, 0)); + + roles = submodel4Spy.dataChangedSpy.at(0).at(2).value>(); + QCOMPARE(roles, expectedRoles); + + QCOMPARE(submodel5Spy.count(), 0); + } + { + ModelSignalsSpy signalsSpy(&model); + ModelSignalsSpy submodel1Spy(submodel1); + ModelSignalsSpy submodel2Spy(submodel2); + ModelSignalsSpy submodel3Spy(submodel3); + ModelSignalsSpy submodel4Spy(submodel4); + ModelSignalsSpy submodel5Spy(submodel5); + + emit sourceModel.model()->dataChanged(sourceModel.model()->index(4, 0), + sourceModel.model()->index(6, 0)); + + QCOMPARE(signalsSpy.count(), 0); + QCOMPARE(submodel1Spy.count(), 0); + QCOMPARE(submodel2Spy.count(), 0); + QCOMPARE(submodel3Spy.count(), 1); + QCOMPARE(submodel3Spy.dataChangedSpy.count(), 1); + QCOMPARE(submodel3Spy.dataChangedSpy.at(0).at(0), submodel3->index(1, 0)); + QCOMPARE(submodel3Spy.dataChangedSpy.at(0).at(1), submodel3->index(3, 0)); + QVERIFY(submodel3Spy.dataChangedSpy.at(0).at(2).value>().isEmpty()); + + QCOMPARE(submodel4Spy.count(), 0); + QCOMPARE(submodel5Spy.count(), 0); + } + { + ModelSignalsSpy signalsSpy(&model); + ModelSignalsSpy submodel1Spy(submodel1); + ModelSignalsSpy submodel2Spy(submodel2); + ModelSignalsSpy submodel3Spy(submodel3); + ModelSignalsSpy submodel4Spy(submodel4); + ModelSignalsSpy submodel5Spy(submodel5); + + emit sourceModel.model()->dataChanged(sourceModel.model()->index(2, 0), + sourceModel.model()->index(7, 0), + { subnameRole }); + + QCOMPARE(signalsSpy.count(), 1); + QCOMPARE(signalsSpy.dataChangedSpy.count(), 1); + QCOMPARE(signalsSpy.dataChangedSpy.at(0).at(0), model.index(1, 0)); + QCOMPARE(signalsSpy.dataChangedSpy.at(0).at(1), model.index(3, 0)); + + QVector expectedRoles{subnameRole}; + auto roles = signalsSpy.dataChangedSpy.at(0).at(2).value>(); + + QCOMPARE(roles, expectedRoles); + + QCOMPARE(submodel1Spy.count(), 0); + QCOMPARE(submodel2Spy.count(), 1); + QCOMPARE(submodel2Spy.dataChangedSpy.count(), 1); + QCOMPARE(submodel2Spy.dataChangedSpy.at(0).at(0), submodel2->index(0, 0)); + QCOMPARE(submodel2Spy.dataChangedSpy.at(0).at(1), submodel2->index(0, 0)); + + QCOMPARE(submodel3Spy.count(), 1); + QCOMPARE(submodel3Spy.dataChangedSpy.count(), 1); + QCOMPARE(submodel3Spy.dataChangedSpy.at(0).at(0), submodel3->index(0, 0)); + QCOMPARE(submodel3Spy.dataChangedSpy.at(0).at(1), submodel3->index(3, 0)); + + QCOMPARE(submodel4Spy.count(), 1); + QCOMPARE(submodel4Spy.dataChangedSpy.count(), 1); + QCOMPARE(submodel4Spy.dataChangedSpy.at(0).at(0), submodel4->index(0, 0)); + QCOMPARE(submodel4Spy.dataChangedSpy.at(0).at(1), submodel4->index(0, 0)); + + QCOMPARE(submodel5Spy.count(), 0); + } + + QVERIFY(isSame(model, *expected.model())); + } + + void setSourceTest() + { + QQmlEngine engine; + + ListModelWrapper sourceModel1(engine); + ListModelWrapper sourceModel2(engine); + + auto contains = [](auto roles, auto name) { + return std::find(roles.cbegin(), roles.cend(), name) != roles.cend(); + }; + + GroupingModel model; + auto signalsSpy = std::make_unique(&model); + + model.setGroupingRoleName("name"); + model.setSourceModel(sourceModel1.model()); // set empty source model + + QCOMPARE(signalsSpy->count(), 2); + QCOMPARE(signalsSpy->modelAboutToBeResetSpy.count(), 1); + QCOMPARE(signalsSpy->modelResetSpy.count(), 1); + + QCOMPARE(model.rowCount(), 0); + + { + auto roles = model.roleNames(); + QCOMPARE(roles.size(), 0); + } + + sourceModel1.append(QJsonArray { // append to empty source model + QJsonObject {{ "name", "A"}, { "subname", "a1" }}, + QJsonObject {{ "name", "A"}, { "subname", "a2" }}, + QJsonObject {{ "name", "B"}, { "subname", "b1" }} + }); + + QCOMPARE(signalsSpy->count(), 4); + QCOMPARE(signalsSpy->rowsAboutToBeInsertedSpy.count(), 1); + QCOMPARE(signalsSpy->rowsInsertedSpy.count(), 1); + signalsSpy = std::make_unique(&model); + + QCOMPARE(model.rowCount(), 2); + + { + const auto roles = model.roleNames(); + QCOMPARE(roles.size(), 3); + QVERIFY(contains(roles, "name")); + QVERIFY(contains(roles, "subname")); + } + + model.setSourceModel(sourceModel2); // set empty source model + + QCOMPARE(signalsSpy->count(), 2); + QCOMPARE(signalsSpy->modelAboutToBeResetSpy.count(), 1); + QCOMPARE(signalsSpy->modelResetSpy.count(), 1); + + QCOMPARE(model.rowCount(), 0); + + { + const auto roles = model.roleNames(); + QCOMPARE(roles.size(), 0); + } + + sourceModel2.append(QJsonArray { // append to empty source model + QJsonObject {{ "name", "A"}, { "subname_", "a1" }}, + QJsonObject {{ "name", "A"}, { "subname_", "a2" }}, + QJsonObject {{ "name", "B"}, { "subname_", "b1" }} + }); + + QCOMPARE(signalsSpy->count(), 4); + QCOMPARE(signalsSpy->rowsAboutToBeInsertedSpy.count(), 1); + QCOMPARE(signalsSpy->rowsInsertedSpy.count(), 1); + signalsSpy = std::make_unique(&model); + + QCOMPARE(model.rowCount(), 2); + + { + const auto roles = model.roleNames(); + QCOMPARE(roles.size(), 3); + QVERIFY(contains(roles, "name")); + QVERIFY(contains(roles, "subname_")); + } + + model.setSourceModel(nullptr); // set null source model + + QCOMPARE(signalsSpy->count(), 2); + QCOMPARE(signalsSpy->modelAboutToBeResetSpy.count(), 1); + QCOMPARE(signalsSpy->modelResetSpy.count(), 1); + + QCOMPARE(model.rowCount(), 0); + + model.setSourceModel(nullptr); // set null source model + + QCOMPARE(signalsSpy->count(), 2); + signalsSpy = std::make_unique(&model); + + { + const auto roles = model.roleNames(); + QCOMPARE(roles.size(), 0); + } + + QCOMPARE(signalsSpy->count(), 0); + + model.setSourceModel(sourceModel2); // set not empty source model + + QCOMPARE(signalsSpy->count(), 2); + QCOMPARE(signalsSpy->modelAboutToBeResetSpy.count(), 1); + QCOMPARE(signalsSpy->modelResetSpy.count(), 1); + signalsSpy = std::make_unique(&model); + + QCOMPARE(model.rowCount(), 2); + + { + const auto roles = model.roleNames(); + QCOMPARE(roles.size(), 3); + QVERIFY(contains(roles, "name")); + QVERIFY(contains(roles, "subname_")); + } + + model.setSourceModel(sourceModel1); // set not empty source model + + QCOMPARE(signalsSpy->count(), 2); + QCOMPARE(signalsSpy->modelAboutToBeResetSpy.count(), 1); + QCOMPARE(signalsSpy->modelResetSpy.count(), 1); + + QCOMPARE(model.rowCount(), 2); + + { + const auto roles = model.roleNames(); + QCOMPARE(roles.size(), 3); + QVERIFY(contains(roles, "name")); + QVERIFY(contains(roles, "subname")); + } + } + + void setSubmodelRoleTest() + { + QQmlEngine engine; + + ListModelWrapper sourceModel(engine, QJsonArray { + QJsonObject {{ "name", "A"}, { "subname", "a1" }}, + QJsonObject {{ "name", "A"}, { "subname", "a2" }}, + QJsonObject {{ "name", "B"}, { "subname", "b1" }}, + QJsonObject {{ "name", "B"}, { "subname", "b2" }}, + QJsonObject {{ "name", "C"}, { "subname", "c1" }} + }); + + ListModelWrapper expected(engine, R"([ + { + "name": "A", "subname": "a1", + "subnames": [ + { "name": "A", "subname": "a1" }, + { "name": "A", "subname": "a2" } + ] + }, + { + "name": "B", "subname": "b1", + "subnames": [ + { "name": "B", "subname": "b1" }, + { "name": "B", "subname": "b2" } + ] + }, + { + "name": "C", "subname": "c1", + "subnames": [ + { "name": "C", "subname": "c1" } + ] + } + ])"); + + auto contains = [](auto roles, auto name) { + return std::find(roles.cbegin(), roles.cend(), name) != roles.cend(); + }; + + { + GroupingModel model; + auto signalsSpy = std::make_unique(&model); + + model.setGroupingRoleName("name"); + model.setSubmodelRoleName("subnames"); + + model.setSourceModel(sourceModel.model()); + + QCOMPARE(signalsSpy->count(), 2); + QCOMPARE(signalsSpy->modelAboutToBeResetSpy.count(), 1); + QCOMPARE(signalsSpy->modelResetSpy.count(), 1); + + QCOMPARE(model.rowCount(), 3); + + const auto roles = model.roleNames(); + QCOMPARE(roles.size(), 3); + QVERIFY(contains(roles, "name")); + QVERIFY(contains(roles, "subname")); + QVERIFY(contains(roles, "subnames")); + + QVERIFY(isSame(model, *expected.model())); + } + + { + GroupingModel model; + auto signalsSpy = std::make_unique(&model); + + model.setGroupingRoleName("name"); + model.setSourceModel(sourceModel.model()); + model.setSubmodelRoleName("subnames"); + + QCOMPARE(signalsSpy->count(), 4); + QCOMPARE(signalsSpy->modelAboutToBeResetSpy.count(), 2); + QCOMPARE(signalsSpy->modelResetSpy.count(), 2); + + QCOMPARE(model.rowCount(), 3); + + const auto roles = model.roleNames(); + QCOMPARE(roles.size(), 3); + QVERIFY(contains(roles, "name")); + QVERIFY(contains(roles, "subname")); + QVERIFY(contains(roles, "subnames")); + + QVERIFY(isSame(model, *expected.model())); + } + } + + void preppendToFirstGroupTest() + { + QQmlEngine engine; + + ListModelWrapper sourceModel(engine, QJsonArray { + QJsonObject {{ "name", "A"}, { "subname", "a1" }}, + QJsonObject {{ "name", "A"}, { "subname", "a2" }}, + QJsonObject {{ "name", "B"}, { "subname", "b1" }}, + QJsonObject {{ "name", "B"}, { "subname", "b2" }}, + QJsonObject {{ "name", "C"}, { "subname", "c1" }} + }); + + GroupingModel model; + + model.setGroupingRoleName("name"); + model.setSourceModel(sourceModel.model()); + + QCOMPARE(model.rowCount(), 3); + + auto roles = model.roleNames(); + QCOMPARE(roles.size(), 3); + + SnapshotModel snapshot(model); + + auto submodelRole = roleForName(roles, "submodel"); + + auto submodel1 = model.data(model.index(0, 0), submodelRole) + .value(); + auto submodel2 = model.data(model.index(1, 0), submodelRole) + .value(); + auto submodel3 = model.data(model.index(2, 0), submodelRole) + .value(); + + ListModelWrapper expected(engine, R"([ + { + "name": "A", "subname": "a0", + "submodel": [ + { "name": "A", "subname": "a0" }, + { "name": "A", "subname": "a1" }, + { "name": "A", "subname": "a2" } + ] + }, + { + "name": "B", "subname": "b1", + "submodel": [ + { "name": "B", "subname": "b1" }, + { "name": "B", "subname": "b2" } + ] + }, + { + "name": "C", "subname": "c1", + "submodel": [ + { "name": "C", "subname": "c1" } + ] + } + ])"); + + QObject context; + connect(submodel1, &QAbstractItemModel::rowsAboutToBeInserted, &context, + [m = &model, s = &snapshot] { + QVERIFY(isSame(m, s)); + }); + connect(submodel1, &QAbstractItemModel::rowsInserted, &context, + [m = &model, expected = expected.model()] { + QVERIFY(isSame(m, expected)); + }); + + ModelSignalsSpy signalsSpy(&model); + ModelSignalsSpy submodel1Spy(submodel1); + ModelSignalsSpy submodel2Spy(submodel2); + ModelSignalsSpy submodel3Spy(submodel3); + + sourceModel.insert(0, QJsonObject { + { "name", "A"}, { "subname", "a0" } + }); + + QCOMPARE(signalsSpy.dataChangedSpy.count(), 1); + QCOMPARE(signalsSpy.dataChangedSpy.at(0).at(0), model.index(0)); + QCOMPARE(signalsSpy.dataChangedSpy.at(0).at(1), model.index(0)); + QCOMPARE(signalsSpy.dataChangedSpy.at(0).at(2).value>(), + { roleForName(roles, "subname") }); + + QCOMPARE(submodel1Spy.rowsAboutToBeInsertedSpy.count(), 1); + QCOMPARE(submodel1Spy.rowsAboutToBeInsertedSpy.at(0).at(0), InvalidIdx); + QCOMPARE(submodel1Spy.rowsAboutToBeInsertedSpy.at(0).at(1), 0); + QCOMPARE(submodel1Spy.rowsAboutToBeInsertedSpy.at(0).at(2), 0); + + QCOMPARE(submodel1Spy.rowsInsertedSpy.count(), 1); + QCOMPARE(submodel1Spy.rowsInsertedSpy.at(0).at(0), InvalidIdx); + QCOMPARE(submodel1Spy.rowsInsertedSpy.at(0).at(1), 0); + QCOMPARE(submodel1Spy.rowsInsertedSpy.at(0).at(2), 0); + + QCOMPARE(signalsSpy.count(), 1); + QCOMPARE(submodel1Spy.count(), 2); + QCOMPARE(submodel2Spy.count(), 0); + QCOMPARE(submodel3Spy.count(), 0); + + QVERIFY(isSame(model, *expected.model())); + } + + void preppendToGroupTest() + { + QQmlEngine engine; + + ListModelWrapper sourceModel(engine, QJsonArray { + QJsonObject {{ "name", "A"}, { "subname", "a1" }}, + QJsonObject {{ "name", "A"}, { "subname", "a2" }}, + QJsonObject {{ "name", "B"}, { "subname", "b1" }}, + QJsonObject {{ "name", "B"}, { "subname", "b2" }}, + QJsonObject {{ "name", "C"}, { "subname", "c1" }} + }); + + GroupingModel model; + + model.setGroupingRoleName("name"); + model.setSourceModel(sourceModel.model()); + + QCOMPARE(model.rowCount(), 3); + + auto roles = model.roleNames(); + QCOMPARE(roles.size(), 3); + + SnapshotModel snapshot(model); + + auto submodelRole = roleForName(roles, "submodel"); + + auto submodel1 = model.data(model.index(0, 0), submodelRole) + .value(); + auto submodel2 = model.data(model.index(1, 0), submodelRole) + .value(); + auto submodel3 = model.data(model.index(2, 0), submodelRole) + .value(); + + ListModelWrapper expected(engine, R"([ + { + "name": "A", "subname": "a1", + "submodel": [ + { "name": "A", "subname": "a1" }, + { "name": "A", "subname": "a2" } + ] + }, + { + "name": "B", "subname": "b01", + "submodel": [ + { "name": "B", "subname": "b01" }, + { "name": "B", "subname": "b02" }, + { "name": "B", "subname": "b1" }, + { "name": "B", "subname": "b2" } + ] + }, + { + "name": "C", "subname": "c1", + "submodel": [ + { "name": "C", "subname": "c1" } + ] + } + ])"); + + QObject context; + connect(submodel2, &QAbstractItemModel::rowsAboutToBeInserted, &context, + [m = &model, s = &snapshot] { + QVERIFY(isSame(m, s)); + }); + connect(submodel2, &QAbstractItemModel::rowsInserted, &context, + [m = &model, expected = expected.model()] { + QVERIFY(isSame(m, expected)); + }); + + ModelSignalsSpy signalsSpy(&model); + ModelSignalsSpy submodel1Spy(submodel1); + ModelSignalsSpy submodel2Spy(submodel2); + ModelSignalsSpy submodel3Spy(submodel3); + + sourceModel.insert(2, QJsonArray { + QJsonObject {{ "name", "B"}, { "subname", "b01" }}, + QJsonObject {{ "name", "B"}, { "subname", "b02" }} + }); + + QCOMPARE(signalsSpy.dataChangedSpy.count(), 1); + QCOMPARE(signalsSpy.dataChangedSpy.at(0).at(0), model.index(1)); + QCOMPARE(signalsSpy.dataChangedSpy.at(0).at(1), model.index(1)); + QCOMPARE(signalsSpy.dataChangedSpy.at(0).at(2).value>(), + { roleForName(roles, "subname") }); + + QCOMPARE(submodel2Spy.rowsAboutToBeInsertedSpy.count(), 1); + QCOMPARE(submodel2Spy.rowsAboutToBeInsertedSpy.at(0).at(0), InvalidIdx); + QCOMPARE(submodel2Spy.rowsAboutToBeInsertedSpy.at(0).at(1), 0); + QCOMPARE(submodel2Spy.rowsAboutToBeInsertedSpy.at(0).at(2), 1); + + QCOMPARE(submodel2Spy.rowsInsertedSpy.count(), 1); + QCOMPARE(submodel2Spy.rowsInsertedSpy.at(0).at(0), InvalidIdx); + QCOMPARE(submodel2Spy.rowsInsertedSpy.at(0).at(1), 0); + QCOMPARE(submodel2Spy.rowsInsertedSpy.at(0).at(2), 1); + + QCOMPARE(signalsSpy.count(), 1); + QCOMPARE(submodel1Spy.count(), 0); + QCOMPARE(submodel2Spy.count(), 2); + QCOMPARE(submodel3Spy.count(), 0); + + QVERIFY(isSame(model, *expected.model())); + } + + void insertToGroupTest() + { + QQmlEngine engine; + + ListModelWrapper sourceModel(engine, QJsonArray { + QJsonObject {{ "name", "A"}, { "subname", "a1" }}, + QJsonObject {{ "name", "A"}, { "subname", "a2" }}, + QJsonObject {{ "name", "B"}, { "subname", "b1" }}, + QJsonObject {{ "name", "B"}, { "subname", "b2" }}, + QJsonObject {{ "name", "C"}, { "subname", "c1" }} + }); + + GroupingModel model; + + model.setGroupingRoleName("name"); + model.setSourceModel(sourceModel.model()); + + QCOMPARE(model.rowCount(), 3); + + auto roles = model.roleNames(); + QCOMPARE(roles.size(), 3); + + SnapshotModel snapshot(model); + + auto submodelRole = roleForName(roles, "submodel"); + + auto submodel1 = model.data(model.index(0, 0), submodelRole) + .value(); + auto submodel2 = model.data(model.index(1, 0), submodelRole) + .value(); + auto submodel3 = model.data(model.index(2, 0), submodelRole) + .value(); + + ListModelWrapper expected(engine, R"([ + { + "name": "A", "subname": "a1", + "submodel": [ + { "name": "A", "subname": "a1" }, + { "name": "A", "subname": "a11" }, + { "name": "A", "subname": "a12" }, + { "name": "A", "subname": "a2" } + ] + }, + { + "name": "B", "subname": "b1", + "submodel": [ + { "name": "B", "subname": "b1" }, + { "name": "B", "subname": "b2" } + ] + }, + { + "name": "C", "subname": "c1", + "submodel": [ + { "name": "C", "subname": "c1" } + ] + } + ])"); + + QObject context; + connect(submodel1, &QAbstractItemModel::rowsAboutToBeInserted, &context, + [m = &model, s = &snapshot] { + QVERIFY(isSame(m, s)); + }); + + connect(submodel1, &QAbstractItemModel::rowsInserted, &context, + [m = &model, expected = expected.model()] { + QVERIFY(isSame(m, expected)); + }); + + ModelSignalsSpy signalsSpy(&model); + ModelSignalsSpy submodel1Spy(submodel1); + ModelSignalsSpy submodel2Spy(submodel2); + ModelSignalsSpy submodel3Spy(submodel3); + + sourceModel.insert(1, QJsonArray { + QJsonObject {{ "name", "A"}, { "subname", "a11" }}, + QJsonObject {{ "name", "A"}, { "subname", "a12" }} + }); + + QCOMPARE(submodel1Spy.rowsAboutToBeInsertedSpy.count(), 1); + QCOMPARE(submodel1Spy.rowsAboutToBeInsertedSpy.at(0).at(0), InvalidIdx); + QCOMPARE(submodel1Spy.rowsAboutToBeInsertedSpy.at(0).at(1), 1); + QCOMPARE(submodel1Spy.rowsAboutToBeInsertedSpy.at(0).at(2), 2); + + QCOMPARE(submodel1Spy.rowsInsertedSpy.count(), 1); + QCOMPARE(submodel1Spy.rowsInsertedSpy.at(0).at(0), InvalidIdx); + QCOMPARE(submodel1Spy.rowsInsertedSpy.at(0).at(1), 1); + QCOMPARE(submodel1Spy.rowsInsertedSpy.at(0).at(2), 2); + + QCOMPARE(signalsSpy.count(), 0); + QCOMPARE(submodel1Spy.count(), 2); + QCOMPARE(submodel2Spy.count(), 0); + QCOMPARE(submodel3Spy.count(), 0); + + QVERIFY(isSame(model, *expected.model())); + } + + void insertToAdjacentGroupsTest() + { + QQmlEngine engine; + + ListModelWrapper sourceModel(engine, QJsonArray { + QJsonObject {{ "name", "A"}, { "subname", "a1" }}, + QJsonObject {{ "name", "A"}, { "subname", "a2" }}, + QJsonObject {{ "name", "B"}, { "subname", "b1" }}, + QJsonObject {{ "name", "B"}, { "subname", "b2" }}, + QJsonObject {{ "name", "C"}, { "subname", "c1" }} + }); + + GroupingModel model; + + model.setGroupingRoleName("name"); + model.setSourceModel(sourceModel.model()); + + QCOMPARE(model.rowCount(), 3); + + auto roles = model.roleNames(); + QCOMPARE(roles.size(), 3); + + SnapshotModel snapshot(model); + + auto submodelRole = roleForName(roles, "submodel"); + + auto submodel1 = model.data(model.index(0, 0), submodelRole) + .value(); + auto submodel2 = model.data(model.index(1, 0), submodelRole) + .value(); + auto submodel3 = model.data(model.index(2, 0), submodelRole) + .value(); + + ListModelWrapper expected(engine, R"([ + { + "name": "A", "subname": "a1", + "submodel": [ + { "name": "A", "subname": "a1" }, + { "name": "A", "subname": "a2" }, + { "name": "A", "subname": "a3" }, + { "name": "A", "subname": "a4" } + ] + }, + { + "name": "B", "subname": "b01", + "submodel": [ + { "name": "B", "subname": "b01" }, + { "name": "B", "subname": "b02" }, + { "name": "B", "subname": "b1" }, + { "name": "B", "subname": "b2" } + ] + }, + { + "name": "C", "subname": "c1", + "submodel": [ + { "name": "C", "subname": "c1" } + ] + } + ])"); + + QObject context; + connect(submodel1, &QAbstractItemModel::rowsAboutToBeInserted, &context, + [m = &model, s = &snapshot] { + QVERIFY(isSame(m, s)); + }); + connect(submodel1, &QAbstractItemModel::rowsInserted, &context, + [m = &model, expected = expected.model()] { + QVERIFY(isSame(m, expected)); + }); + connect(submodel2, &QAbstractItemModel::rowsAboutToBeInserted, &context, + [m = &model, s = &snapshot] { + QVERIFY(isSame(m, s)); + }); + connect(submodel2, &QAbstractItemModel::rowsInserted, &context, + [m = &model, expected = expected.model()] { + QVERIFY(isSame(*m, *expected)); + }); + + ModelSignalsSpy signalsSpy(&model); + ModelSignalsSpy submodel1Spy(submodel1); + ModelSignalsSpy submodel2Spy(submodel2); + ModelSignalsSpy submodel3Spy(submodel3); + + sourceModel.insert(2, QJsonArray { + QJsonObject {{ "name", "A"}, { "subname", "a3" }}, + QJsonObject {{ "name", "A"}, { "subname", "a4" }}, + QJsonObject {{ "name", "B"}, { "subname", "b01" }}, + QJsonObject {{ "name", "B"}, { "subname", "b02" }} + }); + + QCOMPARE(signalsSpy.dataChangedSpy.count(), 1); + QCOMPARE(signalsSpy.dataChangedSpy.at(0).at(0), model.index(1)); + QCOMPARE(signalsSpy.dataChangedSpy.at(0).at(1), model.index(1)); + QCOMPARE(signalsSpy.dataChangedSpy.at(0).at(2).value>(), + { roleForName(roles, "subname") }); + + QCOMPARE(submodel1Spy.rowsAboutToBeInsertedSpy.count(), 1); + QCOMPARE(submodel1Spy.rowsAboutToBeInsertedSpy.at(0).at(0), InvalidIdx); + QCOMPARE(submodel1Spy.rowsAboutToBeInsertedSpy.at(0).at(1), 2); + QCOMPARE(submodel1Spy.rowsAboutToBeInsertedSpy.at(0).at(2), 3); + + QCOMPARE(submodel1Spy.rowsInsertedSpy.count(), 1); + QCOMPARE(submodel1Spy.rowsInsertedSpy.at(0).at(0), InvalidIdx); + QCOMPARE(submodel1Spy.rowsInsertedSpy.at(0).at(1), 2); + QCOMPARE(submodel1Spy.rowsInsertedSpy.at(0).at(2), 3); + + QCOMPARE(submodel2Spy.rowsAboutToBeInsertedSpy.count(), 1); + QCOMPARE(submodel2Spy.rowsAboutToBeInsertedSpy.at(0).at(0), InvalidIdx); + QCOMPARE(submodel2Spy.rowsAboutToBeInsertedSpy.at(0).at(1), 0); + QCOMPARE(submodel2Spy.rowsAboutToBeInsertedSpy.at(0).at(2), 1); + + QCOMPARE(submodel2Spy.rowsInsertedSpy.count(), 1); + QCOMPARE(submodel2Spy.rowsInsertedSpy.at(0).at(0), InvalidIdx); + QCOMPARE(submodel2Spy.rowsInsertedSpy.at(0).at(1), 0); + QCOMPARE(submodel2Spy.rowsInsertedSpy.at(0).at(2), 1); + + QCOMPARE(signalsSpy.count(), 1); + QCOMPARE(submodel1Spy.count(), 2); + QCOMPARE(submodel2Spy.count(), 2); + QCOMPARE(submodel3Spy.count(), 0); + + QVERIFY(isSame(model, *expected.model())); + } + + void insertNewGroupTest() + { + QQmlEngine engine; + + ListModelWrapper sourceModel(engine, QJsonArray { + QJsonObject {{ "name", "A"}, { "subname", "a1" }}, + QJsonObject {{ "name", "A"}, { "subname", "a2" }}, + QJsonObject {{ "name", "B"}, { "subname", "b1" }}, + QJsonObject {{ "name", "B"}, { "subname", "b2" }}, + QJsonObject {{ "name", "C"}, { "subname", "c1" }} + }); + + GroupingModel model; + + model.setGroupingRoleName("name"); + model.setSourceModel(sourceModel.model()); + + QCOMPARE(model.rowCount(), 3); + + auto roles = model.roleNames(); + QCOMPARE(roles.size(), 3); + + SnapshotModel snapshot(model); + + auto submodelRole = roleForName(roles, "submodel"); + + auto submodel1 = model.data(model.index(0, 0), submodelRole) + .value(); + auto submodel2 = model.data(model.index(1, 0), submodelRole) + .value(); + auto submodel3 = model.data(model.index(2, 0), submodelRole) + .value(); + + ListModelWrapper expected(engine, R"([ + { + "name": "A", "subname": "a1", + "submodel": [ + { "name": "A", "subname": "a1" }, + { "name": "A", "subname": "a2" } + ] + }, + { + "name": "AA", "subname": "aa1", + "submodel": [ + { "name": "AA", "subname": "aa1" }, + { "name": "AA", "subname": "aa2" } + ] + }, + { + "name": "AB", "subname": "ab1", + "submodel": [ + { "name": "AB", "subname": "ab1" } + ] + }, + { + "name": "B", "subname": "b1", + "submodel": [ + { "name": "B", "subname": "b1" }, + { "name": "B", "subname": "b2" } + ] + }, + { + "name": "C", "subname": "c1", + "submodel": [ + { "name": "C", "subname": "c1" } + ] + } + ])"); + + QObject context; + connect(&model, &QAbstractItemModel::rowsAboutToBeInserted, &context, + [m = &model, s = &snapshot] { + QVERIFY(isSame(m, s)); + }); + + connect(&model, &QAbstractItemModel::rowsInserted, &context, + [m = &model, expected = expected.model()] { + QVERIFY(isSame(m, expected)); + }); + + ModelSignalsSpy signalsSpy(&model); + ModelSignalsSpy submodel1Spy(submodel1); + ModelSignalsSpy submodel2Spy(submodel2); + ModelSignalsSpy submodel3Spy(submodel3); + + sourceModel.insert(2, QJsonArray { + QJsonObject {{ "name", "AA"}, { "subname", "aa1" }}, + QJsonObject {{ "name", "AA"}, { "subname", "aa2" }}, + QJsonObject {{ "name", "AB"}, { "subname", "ab1" }} + }); + + QCOMPARE(signalsSpy.rowsAboutToBeInsertedSpy.count(), 1); + QCOMPARE(signalsSpy.rowsAboutToBeInsertedSpy.at(0).at(0), InvalidIdx); + QCOMPARE(signalsSpy.rowsAboutToBeInsertedSpy.at(0).at(1), 1); + QCOMPARE(signalsSpy.rowsAboutToBeInsertedSpy.at(0).at(2), 2); + + QCOMPARE(signalsSpy.rowsInsertedSpy.count(), 1); + QCOMPARE(signalsSpy.rowsInsertedSpy.at(0).at(0), InvalidIdx); + QCOMPARE(signalsSpy.rowsInsertedSpy.at(0).at(1), 1); + QCOMPARE(signalsSpy.rowsInsertedSpy.at(0).at(2), 2); + + QCOMPARE(signalsSpy.count(), 2); + QCOMPARE(submodel1Spy.count(), 0); + QCOMPARE(submodel2Spy.count(), 0); + QCOMPARE(submodel3Spy.count(), 0); + + QVERIFY(isSame(model, *expected.model())); + } + + void insertToEmptyModelTest() + { + QQmlEngine engine; + + ListModelWrapper sourceModel(engine); + + GroupingModel model; + model.setGroupingRoleName("name"); + model.setSourceModel(sourceModel.model()); + + QCOMPARE(model.rowCount(), 0); + + ListModelWrapper expected(engine, R"([ + { + "name": "A", "subname": "a1", + "submodel": [ + { "name": "A", "subname": "a1" }, + { "name": "A", "subname": "a2" } + ] + }, + { + "name": "B", "subname": "b1", + "submodel": [ + { "name": "B", "subname": "b1" } + ] + } + ])"); + + QObject context; + connect(&model, &QAbstractItemModel::rowsAboutToBeInserted, &context, + [m = &model] { + QCOMPARE(m->rowCount(), 0); + }); + + connect(&model, &QAbstractItemModel::rowsInserted, &context, + [m = &model, expected = expected.model()] { + QVERIFY(isSame(*m, *expected)); + }); + + ModelSignalsSpy signalsSpy(&model); + + sourceModel.append(QJsonArray { + QJsonObject {{ "name", "A"}, { "subname", "a1" }}, + QJsonObject {{ "name", "A"}, { "subname", "a2" }}, + QJsonObject {{ "name", "B"}, { "subname", "b1" }} + }); + + QCOMPARE(signalsSpy.rowsAboutToBeInsertedSpy.count(), 1); + QCOMPARE(signalsSpy.rowsAboutToBeInsertedSpy.at(0).at(0), InvalidIdx); + QCOMPARE(signalsSpy.rowsAboutToBeInsertedSpy.at(0).at(1), 0); + QCOMPARE(signalsSpy.rowsAboutToBeInsertedSpy.at(0).at(2), 1); + + QCOMPARE(signalsSpy.rowsInsertedSpy.count(), 1); + QCOMPARE(signalsSpy.rowsInsertedSpy.at(0).at(0), InvalidIdx); + QCOMPARE(signalsSpy.rowsInsertedSpy.at(0).at(1), 0); + QCOMPARE(signalsSpy.rowsInsertedSpy.at(0).at(2), 1); + + QCOMPARE(signalsSpy.count(), 2); + QVERIFY(isSame(model, *expected.model())); + } + + void insertNewGroupAndSplitTest() + { + QQmlEngine engine; + + ListModelWrapper sourceModel(engine, QJsonArray { + QJsonObject {{ "name", "A"}, { "subname", "a1" }}, + QJsonObject {{ "name", "A"}, { "subname", "a2" }}, + QJsonObject {{ "name", "B"}, { "subname", "b1" }}, + QJsonObject {{ "name", "B"}, { "subname", "b2" }}, + QJsonObject {{ "name", "C"}, { "subname", "c1" }} + }); + + GroupingModel model; + + model.setGroupingRoleName("name"); + model.setSourceModel(sourceModel.model()); + + QCOMPARE(model.rowCount(), 3); + + auto roles = model.roleNames(); + QCOMPARE(roles.size(), 3); + + SnapshotModel snapshot(model); + + auto submodelRole = roleForName(roles, "submodel"); + + auto submodel1 = model.data(model.index(0, 0), submodelRole) + .value(); + auto submodel2 = model.data(model.index(1, 0), submodelRole) + .value(); + auto submodel3 = model.data(model.index(2, 0), submodelRole) + .value(); + + ListModelWrapper expected(engine, R"([ + { + "name": "A", "subname": "a1", + "submodel": [ + { "name": "A", "subname": "a1" }, + { "name": "A", "subname": "a2" } + ] + }, + { + "name": "B", "subname": "b1", + "submodel": [ + { "name": "B", "subname": "b1" } + ] + }, + { + "name": "D", "subname": "d1", + "submodel": [ + { "name": "D", "subname": "d1" } + ] + }, + { + "name": "B", "subname": "b2", + "submodel": [ + { "name": "B", "subname": "b2" } + ] + }, + { + "name": "C", "subname": "c1", + "submodel": [ + { "name": "C", "subname": "c1" } + ] + } + ])"); + + ListModelWrapper expectedIntermediate(engine, R"([ + { + "name": "A", "subname": "a1", + "submodel": [ + { "name": "A", "subname": "a1" }, + { "name": "A", "subname": "a2" } + ] + }, + { + "name": "B", "subname": "b1", + "submodel": [ + { "name": "B", "subname": "b1" } + ] + }, + { + "name": "C", "subname": "c1", + "submodel": [ + { "name": "C", "subname": "c1" } + ] + } + ])"); + + QObject context; + connect(submodel2, &QAbstractItemModel::rowsAboutToBeRemoved, &context, + [m = &model, s = &snapshot] { + QVERIFY(isSame(m, s)); + }); + connect(submodel2, &QAbstractItemModel::rowsRemoved, &context, + [m = &model, e = expectedIntermediate.model()] { + QVERIFY(isSame(m, e)); + }); + connect(&model, &QAbstractItemModel::rowsAboutToBeInserted, &context, + [m = &model, e = expectedIntermediate.model()] { + QVERIFY(isSame(m, e)); + }); + connect(&model, &QAbstractItemModel::rowsInserted, &context, + [m = &model, e = expected.model()] { + QVERIFY(isSame(m, e)); + }); + + ModelSignalsSpy signalsSpy(&model); + ModelSignalsSpy submodel1Spy(submodel1); + ModelSignalsSpy submodel2Spy(submodel2); + ModelSignalsSpy submodel3Spy(submodel3); + + sourceModel.insert(3, QJsonArray { + QJsonObject {{ "name", "D"}, { "subname", "d1" }} + }); + + QCOMPARE(signalsSpy.rowsAboutToBeInsertedSpy.count(), 1); + QCOMPARE(signalsSpy.rowsInsertedSpy.count(), 1); + + QCOMPARE(signalsSpy.rowsAboutToBeInsertedSpy.count(), 1); + QCOMPARE(signalsSpy.rowsAboutToBeInsertedSpy.at(0).at(0), InvalidIdx); + QCOMPARE(signalsSpy.rowsAboutToBeInsertedSpy.at(0).at(1), 2); + QCOMPARE(signalsSpy.rowsAboutToBeInsertedSpy.at(0).at(2), 3); + + QCOMPARE(signalsSpy.rowsInsertedSpy.count(), 1); + QCOMPARE(signalsSpy.rowsInsertedSpy.at(0).at(0), InvalidIdx); + QCOMPARE(signalsSpy.rowsInsertedSpy.at(0).at(1), 2); + QCOMPARE(signalsSpy.rowsInsertedSpy.at(0).at(2), 3); + + QCOMPARE(submodel2Spy.rowsAboutToBeRemovedSpy.count(), 1); + QCOMPARE(submodel2Spy.rowsAboutToBeRemovedSpy.at(0).at(0), InvalidIdx); + QCOMPARE(submodel2Spy.rowsAboutToBeRemovedSpy.at(0).at(1), 1); + QCOMPARE(submodel2Spy.rowsAboutToBeRemovedSpy.at(0).at(2), 1); + + QCOMPARE(submodel2Spy.rowsRemovedSpy.count(), 1); + QCOMPARE(submodel2Spy.rowsRemovedSpy.at(0).at(0), InvalidIdx); + QCOMPARE(submodel2Spy.rowsRemovedSpy.at(0).at(1), 1); + QCOMPARE(submodel2Spy.rowsRemovedSpy.at(0).at(2), 1); + + QCOMPARE(signalsSpy.count(), 2); + QCOMPARE(submodel1Spy.count(), 0); + QCOMPARE(submodel2Spy.count(), 2); + QCOMPARE(submodel3Spy.count(), 0); + + QVERIFY(isSame(model, *expected.model())); + } + + void insertToGroupAndSplitTest() + { + QQmlEngine engine; + + ListModelWrapper sourceModel(engine, QJsonArray { + QJsonObject {{ "name", "A"}, { "subname", "a1" }}, + QJsonObject {{ "name", "A"}, { "subname", "a2" }}, + QJsonObject {{ "name", "B"}, { "subname", "b1" }}, + QJsonObject {{ "name", "B"}, { "subname", "b2" }}, + QJsonObject {{ "name", "C"}, { "subname", "c1" }} + }); + + GroupingModel model; + + model.setGroupingRoleName("name"); + model.setSourceModel(sourceModel.model()); + + QCOMPARE(model.rowCount(), 3); + + auto roles = model.roleNames(); + QCOMPARE(roles.size(), 3); + + SnapshotModel snapshot(model); + + auto submodelRole = roleForName(roles, "submodel"); + + auto submodel1 = model.data(model.index(0, 0), submodelRole) + .value(); + auto submodel2 = model.data(model.index(1, 0), submodelRole) + .value(); + auto submodel3 = model.data(model.index(2, 0), submodelRole) + .value(); + + ListModelWrapper expected(engine, R"([ + { + "name": "A", "subname": "a1", + "submodel": [ + { "name": "A", "subname": "a1" }, + { "name": "A", "subname": "a2" } + ] + }, + { + "name": "B", "subname": "b1", + "submodel": [ + { "name": "B", "subname": "b1" }, + { "name": "B", "subname": "b11" } + ] + }, + { + "name": "D", "subname": "d1", + "submodel": [ + { "name": "D", "subname": "d1" } + ] + }, + { + "name": "B", "subname": "b12", + "submodel": [ + { "name": "B", "subname": "b12" }, + { "name": "B", "subname": "b2" } + ] + }, + { + "name": "C", "subname": "c1", + "submodel": [ + { "name": "C", "subname": "c1" } + ] + } + ])"); + + ListModelWrapper expectedIntermediate(engine, R"([ + { + "name": "A", "subname": "a1", + "submodel": [ + { "name": "A", "subname": "a1" }, + { "name": "A", "subname": "a2" } + ] + }, + { + "name": "B", "subname": "b1", + "submodel": [ + { "name": "B", "subname": "b1" } + ] + }, + { + "name": "C", "subname": "c1", + "submodel": [ + { "name": "C", "subname": "c1" } + ] + } + ])"); + + QObject context; + connect(submodel2, &QAbstractItemModel::rowsAboutToBeRemoved, &context, + [m = &model, s = &snapshot] { + QVERIFY(isSame(m, s)); + }); + connect(submodel2, &QAbstractItemModel::rowsRemoved, &context, + [m = &model, e = expectedIntermediate.model()] { + QVERIFY(isSame(m, e)); + }); + connect(submodel2, &QAbstractItemModel::rowsAboutToBeInserted, &context, + [m = &model, e = expectedIntermediate.model()] { + QVERIFY(isSame(m, e)); + }); + connect(submodel2, &QAbstractItemModel::rowsInserted, &context, + [m = &model, e = expected.model()] { + QVERIFY(isSame(m, e)); + }); + connect(&model, &QAbstractItemModel::rowsAboutToBeInserted, &context, + [m = &model, e = expectedIntermediate.model()] { + QVERIFY(isSame(m, e)); + }); + connect(&model, &QAbstractItemModel::rowsInserted, &context, + [m = &model, e = expected.model()] { + QVERIFY(isSame(m, e)); + }); + + ModelSignalsSpy signalsSpy(&model); + ModelSignalsSpy submodel1Spy(submodel1); + ModelSignalsSpy submodel2Spy(submodel2); + ModelSignalsSpy submodel3Spy(submodel3); + + sourceModel.insert(3, QJsonArray { + QJsonObject {{ "name", "B"}, { "subname", "b11" }}, + QJsonObject {{ "name", "D"}, { "subname", "d1" }}, + QJsonObject {{ "name", "B"}, { "subname", "b12" }} + }); + + QCOMPARE(signalsSpy.rowsAboutToBeInsertedSpy.count(), 1); + QCOMPARE(signalsSpy.rowsInsertedSpy.count(), 1); + + QCOMPARE(signalsSpy.rowsAboutToBeInsertedSpy.count(), 1); + QCOMPARE(signalsSpy.rowsAboutToBeInsertedSpy.at(0).at(0), InvalidIdx); + QCOMPARE(signalsSpy.rowsAboutToBeInsertedSpy.at(0).at(1), 2); + QCOMPARE(signalsSpy.rowsAboutToBeInsertedSpy.at(0).at(2), 3); + + QCOMPARE(signalsSpy.rowsInsertedSpy.count(), 1); + QCOMPARE(signalsSpy.rowsInsertedSpy.at(0).at(0), InvalidIdx); + QCOMPARE(signalsSpy.rowsInsertedSpy.at(0).at(1), 2); + QCOMPARE(signalsSpy.rowsInsertedSpy.at(0).at(2), 3); + + QCOMPARE(submodel2Spy.rowsAboutToBeRemovedSpy.count(), 1); + QCOMPARE(submodel2Spy.rowsAboutToBeRemovedSpy.at(0).at(0), InvalidIdx); + QCOMPARE(submodel2Spy.rowsAboutToBeRemovedSpy.at(0).at(1), 1); + QCOMPARE(submodel2Spy.rowsAboutToBeRemovedSpy.at(0).at(2), 1); + + QCOMPARE(submodel2Spy.rowsRemovedSpy.count(), 1); + QCOMPARE(submodel2Spy.rowsRemovedSpy.at(0).at(0), InvalidIdx); + QCOMPARE(submodel2Spy.rowsRemovedSpy.at(0).at(1), 1); + QCOMPARE(submodel2Spy.rowsRemovedSpy.at(0).at(2), 1); + + QCOMPARE(submodel2Spy.rowsAboutToBeInsertedSpy.count(), 1); + QCOMPARE(submodel2Spy.rowsAboutToBeInsertedSpy.at(0).at(0), InvalidIdx); + QCOMPARE(submodel2Spy.rowsAboutToBeInsertedSpy.at(0).at(1), 1); + QCOMPARE(submodel2Spy.rowsAboutToBeInsertedSpy.at(0).at(2), 1); + + QCOMPARE(submodel2Spy.rowsInsertedSpy.count(), 1); + QCOMPARE(submodel2Spy.rowsInsertedSpy.at(0).at(0), InvalidIdx); + QCOMPARE(submodel2Spy.rowsInsertedSpy.at(0).at(1), 1); + QCOMPARE(submodel2Spy.rowsInsertedSpy.at(0).at(2), 1); + + QCOMPARE(signalsSpy.count(), 2); + QCOMPARE(submodel1Spy.count(), 0); + QCOMPARE(submodel2Spy.count(), 4); + QCOMPARE(submodel3Spy.count(), 0); + + QVERIFY(isSame(model, *expected.model())); + } + + void insertAtEndTest() + { + QQmlEngine engine; + + ListModelWrapper sourceModel(engine, QJsonArray { + QJsonObject {{ "name", "A"}, { "subname", "a1" }}, + QJsonObject {{ "name", "A"}, { "subname", "a2" }}, + QJsonObject {{ "name", "B"}, { "subname", "b1" }}, + QJsonObject {{ "name", "B"}, { "subname", "b2" }}, + QJsonObject {{ "name", "C"}, { "subname", "c1" }} + }); + + GroupingModel model; + + model.setGroupingRoleName("name"); + model.setSourceModel(sourceModel.model()); + + QCOMPARE(model.rowCount(), 3); + + auto roles = model.roleNames(); + QCOMPARE(roles.size(), 3); + + SnapshotModel snapshot(model); + + auto submodelRole = roleForName(roles, "submodel"); + + auto submodel1 = model.data(model.index(0, 0), submodelRole) + .value(); + auto submodel2 = model.data(model.index(1, 0), submodelRole) + .value(); + auto submodel3 = model.data(model.index(2, 0), submodelRole) + .value(); + + ListModelWrapper expected(engine, R"([ + { + "name": "A", "subname": "a1", + "submodel": [ + { "name": "A", "subname": "a1" }, + { "name": "A", "subname": "a2" } + ] + }, + { + "name": "B", "subname": "b1", + "submodel": [ + { "name": "B", "subname": "b1" }, + { "name": "B", "subname": "b2" } + ] + }, + { + "name": "C", "subname": "c1", + "submodel": [ + { "name": "C", "subname": "c1" }, + { "name": "C", "subname": "c2" }, + { "name": "C", "subname": "c3" } + ] + }, + { + "name": "D", "subname": "d1", + "submodel": [ + { "name": "D", "subname": "d1" } + ] + }, + { + "name": "E", "subname": "e1", + "submodel": [ + { "name": "E", "subname": "e1" } + ] + } + ])"); + + QObject context; + connect(submodel3, &QAbstractItemModel::rowsAboutToBeInserted, &context, + [m = &model, s = &snapshot] { + QVERIFY(isSame(m, s)); + }); + connect(submodel3, &QAbstractItemModel::rowsInserted, &context, + [m = &model, e = expected.model()] { + QVERIFY(isSame(m, e)); + }); + connect(&model, &QAbstractItemModel::rowsAboutToBeInserted, &context, + [m = &model, s = &snapshot] { + QVERIFY(isSame(m, s)); + }); + connect(&model, &QAbstractItemModel::rowsInserted, &context, + [m = &model, e = expected.model()] { + QVERIFY(isSame(m, e)); + }); + + ModelSignalsSpy signalsSpy(&model); + ModelSignalsSpy submodel1Spy(submodel1); + ModelSignalsSpy submodel2Spy(submodel2); + ModelSignalsSpy submodel3Spy(submodel3); + + sourceModel.append(QJsonArray { + QJsonObject {{ "name", "C"}, { "subname", "c2" }}, + QJsonObject {{ "name", "C"}, { "subname", "c3" }}, + QJsonObject {{ "name", "D"}, { "subname", "d1" }}, + QJsonObject {{ "name", "E"}, { "subname", "e1" }} + }); + + QCOMPARE(signalsSpy.rowsAboutToBeInsertedSpy.count(), 1); + QCOMPARE(signalsSpy.rowsAboutToBeInsertedSpy.at(0).at(0), InvalidIdx); + QCOMPARE(signalsSpy.rowsAboutToBeInsertedSpy.at(0).at(1), 3); + QCOMPARE(signalsSpy.rowsAboutToBeInsertedSpy.at(0).at(2), 4); + + QCOMPARE(signalsSpy.rowsInsertedSpy.count(), 1); + QCOMPARE(signalsSpy.rowsInsertedSpy.at(0).at(0), InvalidIdx); + QCOMPARE(signalsSpy.rowsInsertedSpy.at(0).at(1), 3); + QCOMPARE(signalsSpy.rowsInsertedSpy.at(0).at(2), 4); + + QCOMPARE(submodel3Spy.rowsAboutToBeInsertedSpy.count(), 1); + QCOMPARE(submodel3Spy.rowsAboutToBeInsertedSpy.at(0).at(0), InvalidIdx); + QCOMPARE(submodel3Spy.rowsAboutToBeInsertedSpy.at(0).at(1), 1); + QCOMPARE(submodel3Spy.rowsAboutToBeInsertedSpy.at(0).at(2), 2); + + QCOMPARE(submodel3Spy.rowsInsertedSpy.count(), 1); + QCOMPARE(submodel3Spy.rowsInsertedSpy.at(0).at(0), InvalidIdx); + QCOMPARE(submodel3Spy.rowsInsertedSpy.at(0).at(1), 1); + QCOMPARE(submodel3Spy.rowsInsertedSpy.at(0).at(2), 2); + + QCOMPARE(signalsSpy.count(), 2); + QCOMPARE(submodel1Spy.count(), 0); + QCOMPARE(submodel2Spy.count(), 0); + QCOMPARE(submodel3Spy.count(), 2); + + QVERIFY(isSame(model, *expected.model())); + } + + void removeFromBeginingTest() + { + QQmlEngine engine; + + ListModelWrapper sourceModel(engine, QJsonArray { + QJsonObject {{ "name", "A"}, { "subname", "a1" }}, // to be removed + QJsonObject {{ "name", "A"}, { "subname", "a2" }}, // to be removed + QJsonObject {{ "name", "B"}, { "subname", "b1" }}, // to be removed + QJsonObject {{ "name", "C"}, { "subname", "c1" }}, // to be removed + QJsonObject {{ "name", "C"}, { "subname", "c2" }}, // to be removed + QJsonObject {{ "name", "C"}, { "subname", "c3" }}, + QJsonObject {{ "name", "D"}, { "subname", "d1" }} + }); + + GroupingModel model; + + model.setGroupingRoleName("name"); + model.setSourceModel(sourceModel); + + QCOMPARE(model.rowCount(), 4); + + auto roles = model.roleNames(); + QCOMPARE(roles.size(), 3); + + SnapshotModel snapshot(model); + + auto submodelRole = roleForName(roles, "submodel"); + + QPointer submodel1 + = model.data(model.index(0, 0), submodelRole).value(); + QPointer submodel2 + = model.data(model.index(1, 0), submodelRole).value(); + QPointer submodel3 + = model.data(model.index(2, 0), submodelRole).value(); + QPointer submodel4 + = model.data(model.index(3, 0), submodelRole).value(); + + ListModelWrapper expected(engine, R"([ + { + "name": "C", "subname": "c3", + "submodel": [ + { "name": "C", "subname": "c3" } + ] + }, + { + "name": "D", "subname": "d1", + "submodel": [ + { "name": "D", "subname": "d1" } + ] + } + ])"); + + ListModelWrapper expectedIntermediate(engine, R"([ + { + "name": "A", "subname": "a1", + "submodel": [ + { "name": "A", "subname": "a1" }, + { "name": "A", "subname": "a2" } + ] + }, + { + "name": "B", "subname": "b1", + "submodel": [ + { "name": "B", "subname": "b1" } + ] + }, + { + "name": "C", "subname": "c3", + "submodel": [ + { "name": "C", "subname": "c3" } + ] + }, + { + "name": "D", "subname": "d1", + "submodel": [ + { "name": "D", "subname": "d1" } + ] + } + ])"); + + QVERIFY(isSame(model, snapshot)); + + QObject context; + connect(submodel3, &QAbstractItemModel::rowsAboutToBeRemoved, &context, + [m = &model, s = &snapshot] { + QVERIFY(isSame(m, s)); + }); + connect(submodel3, &QAbstractItemModel::rowsRemoved, &context, + [m = &model, e = expectedIntermediate.model()] { + QVERIFY(isSame(m, e)); + }); + connect(&model, &QAbstractItemModel::rowsAboutToBeRemoved, &context, + [m = &model, e = expectedIntermediate.model()] { + QVERIFY(isSame(m, e)); + }); + connect(&model, &QAbstractItemModel::rowsRemoved, &context, + [m = &model, e = expected.model()] { + QVERIFY(isSame(m, e)); + }); + + ModelSignalsSpy signalsSpy(&model); + ModelSignalsSpy submodel1Spy(submodel1); + ModelSignalsSpy submodel2Spy(submodel2); + ModelSignalsSpy submodel3Spy(submodel3); + ModelSignalsSpy submodel4Spy(submodel4); + + sourceModel.remove(0, 5); + QCOMPARE(sourceModel.count(), 2); + + QVERIFY(submodel1.isNull()); + QVERIFY(submodel2.isNull()); + QVERIFY(!submodel3.isNull()); + QVERIFY(!submodel4.isNull()); + + QCOMPARE(signalsSpy.count(), 2); + QCOMPARE(signalsSpy.rowsAboutToBeRemovedSpy.count(), 1); + QCOMPARE(signalsSpy.rowsRemovedSpy.count(), 1); + QCOMPARE(submodel1Spy.count(), 0); + QCOMPARE(submodel2Spy.count(), 0); + QCOMPARE(submodel3Spy.count(), 2); + QCOMPARE(submodel3Spy.rowsAboutToBeRemovedSpy.count(), 1); + QCOMPARE(submodel3Spy.rowsRemovedSpy.count(), 1); + QCOMPARE(submodel4Spy.count(), 0); + + QVERIFY(isSame(&model, expected)); + } + + void removeSingleInTheMiddleTest() + { + QQmlEngine engine; + + ListModelWrapper sourceModel(engine, QJsonArray { + QJsonObject {{ "name", "A"}, { "subname", "a1" }}, + QJsonObject {{ "name", "B"}, { "subname", "b1" }}, + QJsonObject {{ "name", "B"}, { "subname", "b2" }}, // to be removed + QJsonObject {{ "name", "B"}, { "subname", "b3" }}, + QJsonObject {{ "name", "C"}, { "subname", "c1" }} + }); + + GroupingModel model; + + model.setGroupingRoleName("name"); + model.setSourceModel(sourceModel); + + QCOMPARE(model.rowCount(), 3); + + auto roles = model.roleNames(); + QCOMPARE(roles.size(), 3); + + SnapshotModel snapshot(model); + + auto submodelRole = roleForName(roles, "submodel"); + + QPointer submodel1 + = model.data(model.index(0, 0), submodelRole).value(); + QPointer submodel2 + = model.data(model.index(1, 0), submodelRole).value(); + QPointer submodel3 + = model.data(model.index(2, 0), submodelRole).value(); + + ListModelWrapper expected(engine, R"([ + { + "name": "A", "subname": "a1", + "submodel": [ + { "name": "A", "subname": "a1" } + ] + }, + { + "name": "B", "subname": "b1", + "submodel": [ + { "name": "B", "subname": "b1" }, + { "name": "B", "subname": "b3" } + ] + }, + { + "name": "C", "subname": "c1", + "submodel": [ + { "name": "C", "subname": "c1" } + ] + } + ])"); + + QVERIFY(isSame(model, snapshot)); + + QObject context; + connect(submodel2, &QAbstractItemModel::rowsAboutToBeRemoved, &context, + [m = &model, s = &snapshot] { + QVERIFY(isSame(m, s)); + }); + connect(submodel2, &QAbstractItemModel::rowsRemoved, &context, + [m = &model, e = expected.model()] { + QVERIFY(isSame(m, e)); + }); + + ModelSignalsSpy signalsSpy(&model); + ModelSignalsSpy submodel1Spy(submodel1); + ModelSignalsSpy submodel2Spy(submodel2); + ModelSignalsSpy submodel3Spy(submodel3); + + sourceModel.remove(2, 1); + QCOMPARE(model.rowCount(), 3); + + QVERIFY(!submodel1.isNull()); + QVERIFY(!submodel2.isNull()); + QVERIFY(!submodel3.isNull()); + + QCOMPARE(signalsSpy.count(), 0); + QCOMPARE(submodel1Spy.count(), 0); + QCOMPARE(submodel2Spy.count(), 2); + QCOMPARE(submodel2Spy.rowsAboutToBeRemovedSpy.count(), 1); + QCOMPARE(submodel2Spy.rowsRemovedSpy.count(), 1); + QCOMPARE(submodel3Spy.count(), 0); + + QVERIFY(isSame(&model, expected)); + } + + void removeGroupInTheMiddleTest() + { + QQmlEngine engine; + + ListModelWrapper sourceModel(engine, QJsonArray { + QJsonObject {{ "name", "A"}, { "subname", "a1" }}, + QJsonObject {{ "name", "A"}, { "subname", "a2" }}, // to be removed + QJsonObject {{ "name", "B"}, { "subname", "b1" }}, // to be removed + QJsonObject {{ "name", "C"}, { "subname", "c1" }}, // to be removed + QJsonObject {{ "name", "C"}, { "subname", "c2" }}, // to be removed + QJsonObject {{ "name", "C"}, { "subname", "c3" }}, + QJsonObject {{ "name", "D"}, { "subname", "d1" }} + }); + + GroupingModel model; + + model.setGroupingRoleName("name"); + model.setSourceModel(sourceModel); + + QCOMPARE(model.rowCount(), 4); + + auto roles = model.roleNames(); + QCOMPARE(roles.size(), 3); + + SnapshotModel snapshot(model); + + auto submodelRole = roleForName(roles, "submodel"); + + QPointer submodel1 + = model.data(model.index(0, 0), submodelRole).value(); + QPointer submodel2 + = model.data(model.index(1, 0), submodelRole).value(); + QPointer submodel3 + = model.data(model.index(2, 0), submodelRole).value(); + QPointer submodel4 + = model.data(model.index(3, 0), submodelRole).value(); + + ListModelWrapper expected(engine, R"([ + { + "name": "A", "subname": "a1", + "submodel": [ + { "name": "A", "subname": "a1" } + ] + }, + { + "name": "C", "subname": "c3", + "submodel": [ + { "name": "C", "subname": "c3" } + ] + }, + { + "name": "D", "subname": "d1", + "submodel": [ + { "name": "D", "subname": "d1" } + ] + } + ])"); + + ListModelWrapper expectedIntermediate1(engine, R"([ + { + "name": "A", "subname": "a1", + "submodel": [ + { "name": "A", "subname": "a1" } + ] + }, + { + "name": "B", "subname": "b1", + "submodel": [ + { "name": "B", "subname": "b1" } + ] + }, + { + "name": "C", "subname": "c1", + "submodel": [ + { "name": "C", "subname": "c1" }, + { "name": "C", "subname": "c2" }, + { "name": "C", "subname": "c3" } + ] + }, + { + "name": "D", "subname": "d1", + "submodel": [ + { "name": "D", "subname": "d1" } + ] + } + ])"); + + ListModelWrapper expectedIntermediate2(engine, R"([ + { + "name": "A", "subname": "a1", + "submodel": [ + { "name": "A", "subname": "a1" } + ] + }, + { + "name": "B", "subname": "b1", + "submodel": [ + { "name": "B", "subname": "b1" } + ] + }, + { + "name": "C", "subname": "c3", + "submodel": [ + { "name": "C", "subname": "c3" } + ] + }, + { + "name": "D", "subname": "d1", + "submodel": [ + { "name": "D", "subname": "d1" } + ] + } + ])"); + + QVERIFY(isSame(model, snapshot)); + + QObject context; + connect(submodel1, &QAbstractItemModel::rowsAboutToBeRemoved, &context, + [m = &model, s = &snapshot] { + QVERIFY(isSame(m, s)); + }); + connect(submodel1, &QAbstractItemModel::rowsRemoved, &context, + [m = &model, e = expectedIntermediate1.model()] { + QVERIFY(isSame(m, e)); + }); + connect(submodel3, &QAbstractItemModel::rowsAboutToBeRemoved, &context, + [m = &model, e = expectedIntermediate1.model()] { + QVERIFY(isSame(m, e)); + }); + connect(submodel3, &QAbstractItemModel::rowsRemoved, &context, + [m = &model, e = expectedIntermediate2.model()] { + QVERIFY(isSame(m, e)); + }); + connect(&model, &QAbstractItemModel::rowsAboutToBeRemoved, &context, + [m = &model, e = expectedIntermediate2.model()] { + QVERIFY(isSame(m, e)); + }); + connect(&model, &QAbstractItemModel::rowsRemoved, &context, + [m = &model, e = expected.model()] { + QVERIFY(isSame(m, e)); + }); + + ModelSignalsSpy signalsSpy(&model); + ModelSignalsSpy submodel1Spy(submodel1); + ModelSignalsSpy submodel2Spy(submodel2); + ModelSignalsSpy submodel3Spy(submodel3); + ModelSignalsSpy submodel4Spy(submodel4); + + sourceModel.remove(1, 4); + QCOMPARE(sourceModel.count(), 3); + + QVERIFY(!submodel1.isNull()); + QVERIFY(submodel2.isNull()); + QVERIFY(!submodel3.isNull()); + QVERIFY(!submodel4.isNull()); + + QCOMPARE(signalsSpy.count(), 2); + QCOMPARE(signalsSpy.rowsAboutToBeRemovedSpy.count(), 1); + QCOMPARE(signalsSpy.rowsRemovedSpy.count(), 1); + QCOMPARE(submodel1Spy.count(), 2); + QCOMPARE(submodel1Spy.rowsAboutToBeRemovedSpy.count(), 1); + QCOMPARE(submodel1Spy.rowsRemovedSpy.count(), 1); + QCOMPARE(submodel2Spy.count(), 0); + QCOMPARE(submodel3Spy.count(), 2); + QCOMPARE(submodel3Spy.rowsAboutToBeRemovedSpy.count(), 1); + QCOMPARE(submodel3Spy.rowsRemovedSpy.count(), 1); + QCOMPARE(submodel4Spy.count(), 0); + + QVERIFY(isSame(&model, expected)); + } + + void removeAllTest() + { + QQmlEngine engine; + + ListModelWrapper sourceModel(engine, QJsonArray { + QJsonObject {{ "name", "A"}, { "subname", "a1" }}, + QJsonObject {{ "name", "A"}, { "subname", "a2" }}, + QJsonObject {{ "name", "B"}, { "subname", "b1" }}, + QJsonObject {{ "name", "C"}, { "subname", "c1" }}, + QJsonObject {{ "name", "C"}, { "subname", "c2" }}, + QJsonObject {{ "name", "C"}, { "subname", "c3" }}, + QJsonObject {{ "name", "D"}, { "subname", "d1" }} + }); + + GroupingModel model; + + model.setGroupingRoleName("name"); + model.setSourceModel(sourceModel); + + QCOMPARE(model.rowCount(), 4); + + auto roles = model.roleNames(); + QCOMPARE(roles.size(), 3); + + auto submodelRole = roleForName(roles, "submodel"); + + QPointer submodel1 + = model.data(model.index(0, 0), submodelRole).value(); + QPointer submodel2 + = model.data(model.index(1, 0), submodelRole).value(); + QPointer submodel3 + = model.data(model.index(2, 0), submodelRole).value(); + QPointer submodel4 + = model.data(model.index(3, 0), submodelRole).value(); + + ModelSignalsSpy signalsSpy(&model); + + sourceModel.remove(0, 7); + QCOMPARE(sourceModel.count(), 0); + + QVERIFY(submodel1.isNull()); + QVERIFY(submodel2.isNull()); + QVERIFY(submodel3.isNull()); + QVERIFY(submodel4.isNull()); + + QCOMPARE(signalsSpy.count(), 2); + QCOMPARE(signalsSpy.rowsAboutToBeRemovedSpy.count(), 1); + QCOMPARE(signalsSpy.rowsRemovedSpy.count(), 1); + } + + void removeManyAndMergeTest() + { + QQmlEngine engine; + + ListModelWrapper sourceModel(engine, QJsonArray { + QJsonObject {{ "name", "A"}, { "subname", "a1" }}, + QJsonObject {{ "name", "A"}, { "subname", "a2" }}, + QJsonObject {{ "name", "B"}, { "subname", "b1" }}, + QJsonObject {{ "name", "C"}, { "subname", "c1" }}, + QJsonObject {{ "name", "C"}, { "subname", "c2" }}, // to be removed + QJsonObject {{ "name", "D"}, { "subname", "d1" }}, // to be removed + QJsonObject {{ "name", "C"}, { "subname", "c3" }}, // to be removed + QJsonObject {{ "name", "C"}, { "subname", "c4" }} + }); + + GroupingModel model; + + model.setGroupingRoleName("name"); + model.setSourceModel(sourceModel); + + QCOMPARE(model.rowCount(), 5); + + auto roles = model.roleNames(); + QCOMPARE(roles.size(), 3); + + SnapshotModel snapshot(model); + + auto submodelRole = roleForName(roles, "submodel"); + + QPointer submodel1 + = model.data(model.index(0, 0), submodelRole).value(); + QPointer submodel2 + = model.data(model.index(1, 0), submodelRole).value(); + QPointer submodel3 + = model.data(model.index(2, 0), submodelRole).value(); + QPointer submodel4 + = model.data(model.index(3, 0), submodelRole).value(); + QPointer submodel5 + = model.data(model.index(4, 0), submodelRole).value(); + + ListModelWrapper expected(engine, R"([ + { + "name": "A", "subname": "a1", + "submodel": [ + { "name": "A", "subname": "a1" }, + { "name": "A", "subname": "a2" } + ] + }, + { + "name": "B", "subname": "b1", + "submodel": [ + { "name": "B", "subname": "b1" } + ] + }, + { + "name": "C", "subname": "c1", + "submodel": [ + { "name": "C", "subname": "c1" }, + { "name": "C", "subname": "c4" } + ] + } + ])"); + + ListModelWrapper expectedIntermediate1(engine, R"([ + { + "name": "A", "subname": "a1", + "submodel": [ + { "name": "A", "subname": "a1" }, + { "name": "A", "subname": "a2" } + ] + }, + { + "name": "B", "subname": "b1", + "submodel": [ + { "name": "B", "subname": "b1" } + ] + }, + { + "name": "C", "subname": "c1", + "submodel": [ + { "name": "C", "subname": "c1" } + ] + }, + { + "name": "D", "subname": "d1", + "submodel": [ + { "name": "D", "subname": "d1" } + ] + }, + { + "name": "C", "subname": "c3", + "submodel": [ + { "name": "C", "subname": "c3" }, + { "name": "C", "subname": "c4" } + ] + } + ])"); + + ListModelWrapper expectedIntermediate2(engine, R"([ + { + "name": "A", "subname": "a1", + "submodel": [ + { "name": "A", "subname": "a1" }, + { "name": "A", "subname": "a2" } + ] + }, + { + "name": "B", "subname": "b1", + "submodel": [ + { "name": "B", "subname": "b1" } + ] + }, + { + "name": "C", "subname": "c1", + "submodel": [ + { "name": "C", "subname": "c1" }, + ] + } + ])"); + + QObject context; + connect(submodel3, &QAbstractItemModel::rowsAboutToBeRemoved, &context, + [m = &model, s = &snapshot] { + QVERIFY(isSame(m, s)); + }); + connect(submodel3, &QAbstractItemModel::rowsRemoved, &context, + [m = &model, e = expectedIntermediate1.model()] { + QVERIFY(isSame(m, e)); + }); + connect(&model, &QAbstractItemModel::rowsAboutToBeRemoved, &context, + [m = &model, e = expectedIntermediate1.model()] { + QVERIFY(isSame(m, e)); + }); + connect(&model, &QAbstractItemModel::rowsRemoved, &context, + [m = &model, e = expectedIntermediate2.model()] { + QVERIFY(isSame(m, e)); + }); + connect(submodel3, &QAbstractItemModel::rowsAboutToBeInserted, &context, + [m = &model, e = expectedIntermediate2.model()] { + QVERIFY(isSame(m, e)); + }); + connect(submodel3, &QAbstractItemModel::rowsInserted, &context, + [m = &model, e = expected.model()] { + QVERIFY(isSame(m, e)); + }); + + ModelSignalsSpy signalsSpy(&model); + ModelSignalsSpy submodel1Spy(submodel1); + ModelSignalsSpy submodel2Spy(submodel2); + ModelSignalsSpy submodel3Spy(submodel3); + ModelSignalsSpy submodel4Spy(submodel4); + ModelSignalsSpy submodel5Spy(submodel5); + + sourceModel.remove(4, 3); + + QCOMPARE(model.rowCount(), 3); + + QVERIFY(!submodel1.isNull()); + QVERIFY(!submodel2.isNull()); + QVERIFY(!submodel3.isNull()); + QVERIFY(submodel4.isNull()); + QVERIFY(submodel5.isNull()); + + QCOMPARE(signalsSpy.count(), 2); + QCOMPARE(signalsSpy.rowsAboutToBeRemovedSpy.count(), 1); + QCOMPARE(signalsSpy.rowsAboutToBeRemovedSpy.at(0).at(1), 3); + QCOMPARE(signalsSpy.rowsAboutToBeRemovedSpy.at(0).at(2), 4); + QCOMPARE(signalsSpy.rowsRemovedSpy.count(), 1); + + QCOMPARE(submodel1Spy.count(), 0); + QCOMPARE(submodel2Spy.count(), 0); + + QCOMPARE(submodel3Spy.count(), 4); + QCOMPARE(submodel3Spy.rowsAboutToBeRemovedSpy.count(), 1); + QCOMPARE(submodel3Spy.rowsAboutToBeRemovedSpy.at(0).at(1), 1); + QCOMPARE(submodel3Spy.rowsAboutToBeRemovedSpy.at(0).at(2), 1); + QCOMPARE(submodel3Spy.rowsRemovedSpy.count(), 1); + QCOMPARE(submodel3Spy.rowsAboutToBeInsertedSpy.count(), 1); + QCOMPARE(submodel3Spy.rowsAboutToBeInsertedSpy.at(0).at(1), 1); + QCOMPARE(submodel3Spy.rowsAboutToBeInsertedSpy.at(0).at(2), 1); + QCOMPARE(submodel3Spy.rowsInsertedSpy.count(), 1); + + QCOMPARE(submodel4Spy.count(), 0); + QCOMPARE(submodel5Spy.count(), 0); + + QVERIFY(isSame(&model, expected)); + } + + void removeSingleAndMergeTest() + { + QQmlEngine engine; + + ListModelWrapper sourceModel(engine, QJsonArray { + QJsonObject {{ "name", "A"}, { "subname", "a1" }}, + QJsonObject {{ "name", "A"}, { "subname", "a2" }}, + QJsonObject {{ "name", "B"}, { "subname", "b1" }}, + QJsonObject {{ "name", "C"}, { "subname", "c1" }}, + QJsonObject {{ "name", "D"}, { "subname", "d1" }}, // to be removed + QJsonObject {{ "name", "C"}, { "subname", "c2" }} + }); + + GroupingModel model; + + model.setGroupingRoleName("name"); + model.setSourceModel(sourceModel); + + QCOMPARE(model.rowCount(), 5); + + auto roles = model.roleNames(); + QCOMPARE(roles.size(), 3); + + SnapshotModel snapshot(model); + + auto submodelRole = roleForName(roles, "submodel"); + + QPointer submodel1 + = model.data(model.index(0, 0), submodelRole).value(); + QPointer submodel2 + = model.data(model.index(1, 0), submodelRole).value(); + QPointer submodel3 + = model.data(model.index(2, 0), submodelRole).value(); + QPointer submodel4 + = model.data(model.index(3, 0), submodelRole).value(); + QPointer submodel5 + = model.data(model.index(4, 0), submodelRole).value(); + + ListModelWrapper expected(engine, R"([ + { + "name": "A", "subname": "a1", + "submodel": [ + { "name": "A", "subname": "a1" }, + { "name": "A", "subname": "a2" } + ] + }, + { + "name": "B", "subname": "b1", + "submodel": [ + { "name": "B", "subname": "b1" } + ] + }, + { + "name": "C", "subname": "c1", + "submodel": [ + { "name": "C", "subname": "c1" }, + { "name": "C", "subname": "c2" } + ] + } + ])"); + + ListModelWrapper expectedIntermediate(engine, R"([ + { + "name": "A", "subname": "a1", + "submodel": [ + { "name": "A", "subname": "a1" }, + { "name": "A", "subname": "a2" } + ] + }, + { + "name": "B", "subname": "b1", + "submodel": [ + { "name": "B", "subname": "b1" } + ] + }, + { + "name": "C", "subname": "c1", + "submodel": [ + { "name": "C", "subname": "c1" } + ] + } + ])"); + + QObject context; + connect(&model, &QAbstractItemModel::rowsAboutToBeRemoved, &context, + [m = &model, s = &snapshot] { + QVERIFY(isSame(m, s)); + }); + connect(&model, &QAbstractItemModel::rowsRemoved, &context, + [m = &model, e = expectedIntermediate.model()] { + QVERIFY(isSame(m, e)); + }); + connect(submodel3, &QAbstractItemModel::rowsAboutToBeInserted, &context, + [m = &model, e = expectedIntermediate.model()] { + QVERIFY(isSame(m, e)); + }); + connect(submodel3, &QAbstractItemModel::rowsInserted, &context, + [m = &model, e = expected.model()] { + QVERIFY(isSame(m, e)); + }); + + ModelSignalsSpy signalsSpy(&model); + ModelSignalsSpy submodel1Spy(submodel1); + ModelSignalsSpy submodel2Spy(submodel2); + ModelSignalsSpy submodel3Spy(submodel3); + ModelSignalsSpy submodel4Spy(submodel4); + ModelSignalsSpy submodel5Spy(submodel5); + + sourceModel.remove(4, 1); + + QCOMPARE(model.rowCount(), 3); + + QVERIFY(!submodel1.isNull()); + QVERIFY(!submodel2.isNull()); + QVERIFY(!submodel3.isNull()); + QVERIFY(submodel4.isNull()); + QVERIFY(submodel5.isNull()); + + QCOMPARE(signalsSpy.count(), 2); + QCOMPARE(signalsSpy.rowsAboutToBeRemovedSpy.count(), 1); + QCOMPARE(signalsSpy.rowsAboutToBeRemovedSpy.at(0).at(1), 3); + QCOMPARE(signalsSpy.rowsAboutToBeRemovedSpy.at(0).at(2), 4); + QCOMPARE(signalsSpy.rowsRemovedSpy.count(), 1); + + QCOMPARE(submodel1Spy.count(), 0); + QCOMPARE(submodel2Spy.count(), 0); + + QCOMPARE(submodel3Spy.count(), 2); + QCOMPARE(submodel3Spy.rowsAboutToBeInsertedSpy.count(), 1); + QCOMPARE(submodel3Spy.rowsAboutToBeInsertedSpy.at(0).at(1), 1); + QCOMPARE(submodel3Spy.rowsAboutToBeInsertedSpy.at(0).at(2), 1); + QCOMPARE(submodel3Spy.rowsInsertedSpy.count(), 1); + QCOMPARE(submodel4Spy.count(), 0); + QCOMPARE(submodel5Spy.count(), 0); + + QVERIFY(isSame(&model, expected)); + } + + void submodelsDeletionOnDestructionTest() { + QQmlEngine engine; + + ListModelWrapper sourceModel(engine, QJsonArray { + QJsonObject {{ "name", "A"}, { "subname", "a1" }}, + QJsonObject {{ "name", "A"}, { "subname", "a2" }}, + QJsonObject {{ "name", "B"}, { "subname", "b1" }}, + QJsonObject {{ "name", "C"}, { "subname", "c1" }} + }); + + QPointer submodel1, submodel2, submodel3; + + { + GroupingModel model; + + model.setGroupingRoleName("name"); + model.setSourceModel(sourceModel); + + QCOMPARE(model.rowCount(), 3); + + auto roles = model.roleNames(); + auto submodelRole = roleForName(roles, "submodel"); + + submodel1 = model.data(model.index(0, 0), submodelRole).value(); + submodel2 = model.data(model.index(1, 0), submodelRole).value(); + submodel3 = model.data(model.index(2, 0), submodelRole).value(); + + QVERIFY(!submodel1.isNull()); + QVERIFY(!submodel2.isNull()); + QVERIFY(!submodel3.isNull()); + } + + QVERIFY(submodel1.isNull()); + QVERIFY(submodel2.isNull()); + QVERIFY(submodel3.isNull()); + } + + void submodelsDeletionOnResetTest() { + QQmlEngine engine; + + ListModelWrapper sourceModel(engine, QJsonArray { + QJsonObject {{ "name", "A"}, { "subname", "a1" }}, + QJsonObject {{ "name", "A"}, { "subname", "a2" }}, + QJsonObject {{ "name", "B"}, { "subname", "b1" }}, + QJsonObject {{ "name", "C"}, { "subname", "c1" }} + }); + + QPointer submodel1, submodel2, submodel3; + + GroupingModel model; + + model.setGroupingRoleName("name"); + model.setSourceModel(sourceModel); + + QCOMPARE(model.rowCount(), 3); + + auto roles = model.roleNames(); + auto submodelRole = roleForName(roles, "submodel"); + + submodel1 = model.data(model.index(0, 0), submodelRole).value(); + submodel2 = model.data(model.index(1, 0), submodelRole).value(); + submodel3 = model.data(model.index(2, 0), submodelRole).value(); + + QVERIFY(!submodel1.isNull()); + QVERIFY(!submodel2.isNull()); + QVERIFY(!submodel3.isNull()); + + model.setSourceModel(nullptr); + + QVERIFY(submodel1.isNull()); + QVERIFY(submodel2.isNull()); + QVERIFY(submodel3.isNull()); + + QCOMPARE(model.rowCount(), 0); + } + + void submodelResetTest() { + QQmlEngine engine; + + ListModelWrapper sourceModel1(engine, QJsonArray { + QJsonObject {{ "name", "A"}, { "subname", "a1" }}, + QJsonObject {{ "name", "A"}, { "subname", "a2" }}, + QJsonObject {{ "name", "B"}, { "subname", "b1" }}, + QJsonObject {{ "name", "C"}, { "subname", "c1" }} + }); + + ListModelWrapper sourceModel2(engine, QJsonArray { + QJsonObject {{ "name", "A"}, { "other", "a1" }}, + QJsonObject {{ "name", "A"}, { "other", "a2" }}, + QJsonObject {{ "name", "B"}, { "other", "b1" }}, + QJsonObject {{ "name", "C"}, { "other", "c1" }}, + QJsonObject {{ "name", "C"}, { "other", "c2" }}, + QJsonObject {{ "name", "D"}, { "other", "d1" }} + }); + + auto contains = [](auto roles, auto name) { + return std::find(roles.cbegin(), roles.cend(), name) != roles.cend(); + }; + + QIdentityProxyModel identity; + identity.setSourceModel(sourceModel1); + + GroupingModel model; + model.setGroupingRoleName("name"); + model.setSourceModel(&identity); + + { + const auto roles = model.roleNames(); + QCOMPARE(roles.size(), 3); + QVERIFY(contains(roles, "name")); + QVERIFY(contains(roles, "subname")); + QVERIFY(contains(roles, "submodel")); + } + + QCOMPARE(model.rowCount(), 3); + + ModelSignalsSpy signalsSpy(&model); + identity.setSourceModel(sourceModel2); + + { + const auto roles = model.roleNames(); + QCOMPARE(roles.size(), 3); + QVERIFY(contains(roles, "name")); + QVERIFY(contains(roles, "other")); + QVERIFY(contains(roles, "submodel")); + } + + QCOMPARE(signalsSpy.count(), 2); + QCOMPARE(signalsSpy.modelAboutToBeResetSpy.count(), 1); + QCOMPARE(signalsSpy.modelResetSpy.count(), 1); + + QCOMPARE(model.rowCount(), 4); + + identity.setSourceModel(nullptr); + + { + const auto roles = model.roleNames(); + QCOMPARE(roles.size(), 0); + } + + QCOMPARE(signalsSpy.count(), 4); + QCOMPARE(signalsSpy.modelAboutToBeResetSpy.count(), 2); + QCOMPARE(signalsSpy.modelResetSpy.count(), 2); + + QCOMPARE(model.rowCount(), 0); + + model.setSourceModel(nullptr); + + ModelSignalsSpy signalsSpy2(&model); + identity.setSourceModel(sourceModel2); + QCOMPARE(signalsSpy2.count(), 0); + } +}; + +QTEST_MAIN(TestGroupingModel) +#include "tst_GroupingModel.moc"