From e2fa702ec43904cc4dcb4a84d8f7d77831b1ac91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Cie=C5=9Blak?= Date: Wed, 22 Nov 2023 10:28:53 +0100 Subject: [PATCH] feat(StatusQ): QML-oriented proxy model concatenating source models vertically Closes: #12682 --- storybook/pages/ConcatModelPage.qml | 270 ++++ ui/StatusQ/CMakeLists.txt | 2 + ui/StatusQ/include/StatusQ/concatmodel.h | 115 ++ ui/StatusQ/src/concatmodel.cpp | 681 ++++++++ ui/StatusQ/src/plugin.cpp | 3 + ui/StatusQ/tests/CMakeLists.txt | 4 + ui/StatusQ/tests/tst_ConcatModel.cpp | 1875 ++++++++++++++++++++++ 7 files changed, 2950 insertions(+) create mode 100644 storybook/pages/ConcatModelPage.qml create mode 100644 ui/StatusQ/include/StatusQ/concatmodel.h create mode 100644 ui/StatusQ/src/concatmodel.cpp create mode 100644 ui/StatusQ/tests/tst_ConcatModel.cpp diff --git a/storybook/pages/ConcatModelPage.qml b/storybook/pages/ConcatModelPage.qml new file mode 100644 index 0000000000..7afbe657e8 --- /dev/null +++ b/storybook/pages/ConcatModelPage.qml @@ -0,0 +1,270 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +import StatusQ 0.1 + +import Qt.labs.qmlmodels 1.0 + +Item { + id: root + + ListModel { + id: firstModel + + ListElement { + name: "entry 1 (1)" + } + ListElement { + name: "entry 2 (1)" + } + ListElement { + name: "entry 3 (1)" + } + ListElement { + name: "entry 4 (1)" + } + ListElement { + name: "entry 5 (1)" + } + ListElement { + name: "entry 6 (1)" + } + ListElement { + name: "entry 7 (1)" + } + ListElement { + name: "entry 8 (1)" + } + } + + ListModel { + id: secondModel + + ListElement { + name: "entry 1 (2)" + key: 1 + } + ListElement { + key: 2 + name: "entry 2 (2)" + } + ListElement { + key: 3 + name: "entry 3 (2)" + } + } + + ConcatModel { + id: concatModel + + sources: [ + SourceModel { + model: firstModel + markerRoleValue: "first_model" + }, + SourceModel { + model: secondModel + markerRoleValue: "second_model" + } + ] + + markerRoleName: "which_model" + expectedRoles: ["key", "name"] + } + + RowLayout { + anchors.fill: parent + + ColumnLayout { + + Layout.preferredWidth: parent.width / 3 + + ListView { + id: firstModelListView + + spacing: 15 + + Layout.fillWidth: true + Layout.fillHeight: true + + model: firstModel + + delegate: RowLayout { + width: ListView.view.width + + Label { + Layout.fillWidth: true + + font.bold: true + text: model.name + + MouseArea { + anchors.fill: parent + + onClicked: { + firstModel.setProperty(model.index, "name", + firstModel.get(model.index).name + "_") + } + } + } + } + } + + Button { + text: "append" + + onClicked: { + firstModel.append({name: "appended entry (1)"}) + } + } + + Button { + text: "insert at 1" + + onClicked: { + firstModel.insert(1, {name: "inserted entry (1)"}) + } + } + } + + ColumnLayout { + + Layout.preferredWidth: parent.width / 3 + Layout.fillHeight: true + + ListView { + id: secondModelListView + + spacing: 15 + + Layout.fillWidth: true + Layout.fillHeight: true + + model: secondModel + + delegate: RowLayout { + width: ListView.view.width + + Label { + Layout.fillWidth: true + + font.bold: true + text: model.name + + MouseArea { + anchors.fill: parent + + onClicked: secondModel.setProperty( + model.index, "name", + secondModel.get(model.index).name + "_") + } + } + } + } + + Button { + text: "append" + + onClicked: { + secondModel.append({name: "appended entry (1)", key: 34}) + } + } + + Button { + text: "insert at 1" + + onClicked: { + secondModel.insert(1, {name: "inserted entry (1)", key: 999}) + } + } + } + + ColumnLayout { + + Layout.preferredWidth: parent.width / 3 + Layout.fillHeight: true + + ListView { + id: concatListView + + spacing: 15 + + Layout.fillWidth: true + Layout.fillHeight: true + + model: concatModel + + ScrollBar.vertical: ScrollBar {} + + section.property: "which_model" + section.delegate: ColumnLayout { + Label { + height: implicitHeight * 2 + text: section + " inset" + font.pixelSize: 20 + font.bold: true + font.underline: true + + color: "darkred" + + verticalAlignment: Text.AlignVCenter + } + + RowLayout { + CheckBox { + text: "some switch here" + } + CheckBox { + text: "some other switch here" + } + } + } + + delegate: DelegateChooser { + id: chooser + role: "which_model" + + DelegateChoice { + roleValue: "first_model" + + RowLayout { + width: ListView.view.width + + Label { + Layout.fillWidth: true + + font.bold: true + text: model.name + ", " + model.which_model + color: "darkgreen" + } + } + } + + DelegateChoice { + roleValue: "second_model" + + RowLayout { + width: ListView.view.width + + Label { + Layout.fillWidth: true + + font.bold: true + text: model.name + ", " + model.which_model + + " (" + model.key + ")" + color: "darkblue" + } + } + } + } + } + + Label { + text: concatListView.count + } + } + } +} + +// category: Models diff --git a/ui/StatusQ/CMakeLists.txt b/ui/StatusQ/CMakeLists.txt index 08f825988a..758b28042d 100644 --- a/ui/StatusQ/CMakeLists.txt +++ b/ui/StatusQ/CMakeLists.txt @@ -89,6 +89,7 @@ endif() add_library(StatusQ SHARED ${STATUSQ_QRC_COMPILED} include/StatusQ/QClipboardProxy.h + include/StatusQ/concatmodel.h include/StatusQ/leftjoinmodel.h include/StatusQ/modelutilsinternal.h include/StatusQ/permissionutilsinternal.h @@ -102,6 +103,7 @@ add_library(StatusQ SHARED include/StatusQ/singleroleaggregator.h include/StatusQ/sumaggregator.h src/QClipboardProxy.cpp + src/concatmodel.cpp src/leftjoinmodel.cpp src/modelutilsinternal.cpp src/permissionutilsinternal.cpp diff --git a/ui/StatusQ/include/StatusQ/concatmodel.h b/ui/StatusQ/include/StatusQ/concatmodel.h new file mode 100644 index 0000000000..75613943d5 --- /dev/null +++ b/ui/StatusQ/include/StatusQ/concatmodel.h @@ -0,0 +1,115 @@ +#pragma once + +#include +#include +#include + +class SourceModel : public QObject +{ + Q_OBJECT + + Q_PROPERTY(QAbstractItemModel* model READ model + WRITE setModel NOTIFY modelChanged) + + Q_PROPERTY(QString markerRoleValue READ markerRoleValue + WRITE setMarkerRoleValue NOTIFY markerRoleValueChanged) + +public: + explicit SourceModel(QObject* parent = nullptr); + + void setModel(QAbstractItemModel* model); + QAbstractItemModel* model() const; + + void setMarkerRoleValue(const QString& markerRoleValue); + const QString& markerRoleValue() const; + +signals: + void modelAboutToBeChanged(); + void modelChanged(bool deleted); + void markerRoleValueChanged(); + +private: + void onModelDestroyed(); + + QAbstractItemModel* m_model = nullptr; + QString m_markerRoleValue; +}; + +class ConcatModel : public QAbstractListModel, public QQmlParserStatus +{ + Q_OBJECT + Q_INTERFACES(QQmlParserStatus) + + Q_PROPERTY(QQmlListProperty sources READ sources CONSTANT) + + Q_PROPERTY(QString markerRoleName READ markerRoleName + WRITE setMarkerRoleName NOTIFY markerRoleNameChanged) + + Q_PROPERTY(QStringList expectedRoles READ expectedRoles + WRITE setExpectedRoles NOTIFY expectedRolesChanged) + +public: + explicit ConcatModel(QObject *parent = nullptr); + + QQmlListProperty sources(); + + void setMarkerRoleName(const QString& markerRoleName); + const QString& markerRoleName() const; + + void setExpectedRoles(const QStringList& expectedRoles); + const QStringList& expectedRoles() const; + + Q_INVOKABLE int sourceModelRow(int row) const; + Q_INVOKABLE QAbstractItemModel* sourceModel(int row) const; + Q_INVOKABLE int fromSourceRow(const QAbstractItemModel* model, int row) const; + + // QAbstractItemModel interface + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role) const override; + QHash roleNames() const override; + + // QQmlParserStatus interface + void classBegin() override; + void componentComplete() override; + +signals: + void markerRoleNameChanged(); + void expectedRolesChanged(); + +private: + static constexpr auto s_defaultMarkerRoleName = "whichModel"; + + std::pair sourceForIndex(int index) const; + + void initRoles(); + + void initRolesMapping(int index, QAbstractItemModel* model); + void initRolesMapping(); + + void initAllModelsSlots(); + void connectModelSlots(int index, QAbstractItemModel* model); + void disconnectModelSlots(QAbstractItemModel* model); + + int rowCountInternal() const; + int countPrefix(int sourceIndex) const; + void fetchRowCounts(); + + QVector mapFromSourceRoles(int sourceIndex, + const QVector& sourceRoles) const; + + QList m_sources; + QStringList m_expectedRoles; + + QString m_markerRoleName = s_defaultMarkerRoleName; + int m_markerRole = 0; + + bool m_initialized = false; + + QHash m_roleNames; + std::unordered_map m_nameRoles; + + std::vector> m_rolesMappingFromSource; + std::vector> m_rolesMappingToSource; + std::vector m_rolesMappingInitializationFlags; + std::vector m_rowCounts; +}; diff --git a/ui/StatusQ/src/concatmodel.cpp b/ui/StatusQ/src/concatmodel.cpp new file mode 100644 index 0000000000..5e8c9448d2 --- /dev/null +++ b/ui/StatusQ/src/concatmodel.cpp @@ -0,0 +1,681 @@ +#include "StatusQ/concatmodel.h" + +#include + +/*! + \qmltype SourceModel + \instantiates SourceModel + \inqmlmodule StatusQ + \inherits QtObject + \brief Wraps arbitrary QAbstractItemModel to be concatenated with other + models within a \l {ConcatModel}. + + It allows assigning a value of a special marker role in ConcatModel for the + given model. +*/ + +/*! + \qmltype ConcatModel + \instantiates ConcatModel + \inqmlmodule StatusQ + \inherits QAbstractListModel + \brief Proxy model concatenating vertically multiple source models. + + It allows concatenating multiple source models, with same roles, partially + different roles or even totally different roles. The model performs necessary + roles mapping internally. + + The proxy is similar to \l {QConcatenateTablesProxyModel} but QML-ready, + performing all necessary role names remapping. + + Roles are established when the first item appears in one of the sources. + Expected roles can be also declared up-front using expectedRoles property + (because on first insertion some roles may be not yet available via + roleNames() on other models). + + Additionally the model introduces an extra role with a name configurable via + \l {CocatModel::markerRoleName}. Value of this role may be set separately + for each source model in \l {SourceModel} wrapper. This allows to easily + create inserts between models using \l {ListView}'s sections mechanism. + + \qml + ListModel { + id: firstModel + + ListElement { name: "entry 1_1" } + ListElement { name: "entry 1_2" } + ListElement { name: "entry 1_3" } + } + + ListModel { + id: secondModel + + ListElement { + name: "entry 1_2" + key: 1 + } + ListElement { + key: 2 + name: "entry 2 _2" + } + } + + ConcatModel { + id: concatModel + + sources: [ + SourceModel { + model: firstModel + markerRoleValue: "first_model" + }, + SourceModel { + model: secondModel + markerRoleValue: "second_model" + } + ] + + markerRoleName: "which_model" + expectedRoles: ["key", "name"] + } + \endqml +*/ + +SourceModel::SourceModel(QObject* parent) + : QObject{parent} +{ +} + +void SourceModel::setModel(QAbstractItemModel* model) +{ + if (m_model == model) + return; + + if (model) + connect(model, &QObject::destroyed, this, &SourceModel::onModelDestroyed); + + if (m_model) + disconnect(m_model, &QObject::destroyed, this, &SourceModel::onModelDestroyed); + + emit modelAboutToBeChanged(); + m_model = model; + emit modelChanged(false); +} + + +/*! + \qmlproperty any StatusQ::SourceModel::model + + The model that will be concatenated with other models within ConcatModel. +*/ +QAbstractItemModel* SourceModel::model() const +{ + return m_model; +} + +void SourceModel::setMarkerRoleValue(const QString& markerRoleValue) +{ + if (m_markerRoleValue == markerRoleValue) + return; + + m_markerRoleValue = markerRoleValue; + emit markerRoleValueChanged(); +} + +/*! + \qmlproperty string StatusQ::SourceModel::markerRoleValue + + The value that will be exposed from the ConcatModel through the role named + according to \l {ConcatModel::markerRoleName} for the entries coming from + the model defined in SourceModel::model. +*/ +const QString& SourceModel::markerRoleValue() const +{ + return m_markerRoleValue; +} + +void SourceModel::onModelDestroyed() +{ + m_model = nullptr; + emit modelChanged(true); +} + + +ConcatModel::ConcatModel(QObject* parent) + : QAbstractListModel{parent} +{ +} + +/*! + \qmlproperty list StatusQ::ConcatModel::sources + + This property holds the list of \l {SourceModel} wrappers. Every wrapper + holds model which is intended to be concatenated with others within the + proxy. +*/ +QQmlListProperty ConcatModel::sources() +{ + QQmlListProperty listProperty(this, &m_sources); + + listProperty.replace = nullptr; + listProperty.clear = nullptr; + listProperty.removeLast = nullptr; + + listProperty.append = [](auto listProperty, auto element) { + ConcatModel* model = qobject_cast(listProperty->object); + + if (model->m_initialized) { + qWarning() << "Adding sources dynamically is not supported."; + return; + } + + model->m_sources.append(element); + }; + + return listProperty; +} + +void ConcatModel::setMarkerRoleName(const QString& markerRoleName) +{ + if (m_markerRoleName == markerRoleName) + return; + + if (m_markerRoleName != s_defaultMarkerRoleName || m_initialized) { + qWarning() << "Property \"markerRoleName\" is intended to be " + "initialized once before roles initialization and not " + "modified later."; + return; + } + + m_markerRoleName = markerRoleName; + emit markerRoleNameChanged(); +} + +/*! + \qmlproperty string StatusQ::ConcatModel::markerRoleName + + This property contains the name of an extra role allowing to distinguish + source models from the delegate level. +*/ +const QString& ConcatModel::markerRoleName() const +{ + return m_markerRoleName; +} + +void ConcatModel::setExpectedRoles(const QStringList& expectedRoles) +{ + if (m_expectedRoles == expectedRoles) + return; + + if (!m_expectedRoles.isEmpty()) { + qWarning() << "Property \"expectedRoles\" is intended " + "to be initialized once and not changed!"; + return; + } + + m_expectedRoles = expectedRoles; + emit expectedRolesChanged(); +} + +/*! + \qmlproperty list StatusQ::ConcatModel::expectedRoles + + This property allows to predefine a set of roles exposed by ConcatModel. + This is useful when roles are not initially defined for some source models. + For example, for ListModel, roles are not defined as long as the model is + empty. +*/ +const QStringList& ConcatModel::expectedRoles() const +{ + return m_expectedRoles; +} + +/*! + \qmlmethod int StatusQ::ConcatModel::sourceModelRow(row) + + Returns the row index inside the source model for a given row of the proxy. +*/ +int ConcatModel::sourceModelRow(int row) const +{ + auto source = sourceForIndex(row); + return source.first != nullptr ? source.second : -1; +} + +/*! + \qmlmethod QAbstractItemModel* StatusQ::ConcatModel::sourceModel(row) + + Returns the source model for a given row of the proxy. +*/ +QAbstractItemModel* ConcatModel::sourceModel(int row) const +{ + auto source = sourceForIndex(row); + return source.first != nullptr ? source.first->model() : nullptr; +} + +/*! + \qmlmethod int StatusQ::ConcatModel::fromSourceRow(model, row) + + Returns the row number of the ConcatModel for a given source model and + source model's row index. +*/ +int ConcatModel::fromSourceRow(const QAbstractItemModel* model, int row) const +{ + if (model == nullptr || row < 0 || model->rowCount() <= row) + return -1; + + auto it = std::find_if(m_sources.cbegin(), m_sources.cend(), + [model](auto source) { + return source->model() == model; + }); + + if (it == m_sources.cend()) + return -1; + + return countPrefix(it - m_sources.begin()) + row; +} + +int ConcatModel::rowCount(const QModelIndex& parent) const +{ + if (!m_initialized) + return 0; + + return rowCountInternal(); +} + +QVariant ConcatModel::data(const QModelIndex &index, int role) const +{ + if (!checkIndex(index, CheckIndexOption::IndexIsValid)) + return {}; + + auto row = index.row(); + int rowCount = 0; + + for (int i = 0; i < m_sources.size(); i++) { + const int subRowCount = m_rowCounts[i]; + + if (rowCount + subRowCount > row) { + auto source = m_sources[i]; + + if (role == m_markerRole) + return source->markerRoleValue(); + + auto model = source->model(); + + if (model == nullptr) + return {}; + + auto& mapping = m_rolesMappingToSource.at(i); + auto it = mapping.find(role); + + if (it == mapping.end()) + return {}; + + return model->data(model->index(row - rowCount, 0), it->second); + } + + rowCount += subRowCount; + } + + return {}; +} + +QHash ConcatModel::roleNames() const +{ + return m_roleNames; +} + +void ConcatModel::classBegin() +{ +} + +void ConcatModel::componentComplete() +{ + if (m_initialized) + return; + + for (auto i = 0; i < m_sources.size(); i++) { + SourceModel* source = m_sources[i]; + + connect(source, &SourceModel::modelAboutToBeChanged, this, + [this, i, source]() + { + auto model = source->model(); + + if (model != nullptr) + disconnectModelSlots(model); + + if (auto count = m_rowCounts[i]) { + auto prefix = countPrefix(i); + beginRemoveRows({}, prefix, prefix + count - 1); + } + }); + + connect(source, &SourceModel::modelChanged, this, + [this, source, i](bool deleted) + { + auto previousRowCount = m_rowCounts[i]; + + if (deleted) { + auto prefix = countPrefix(i); + beginRemoveRows({}, prefix, prefix + previousRowCount - 1); + } + + if (previousRowCount) { + m_rowCounts[i] = 0; + endRemoveRows(); + } + + auto model = source->model(); + + if (model == nullptr) + return; + + auto rowCount = model->rowCount(); + + if (rowCount > 0) { + auto prefix = countPrefix(i); + + beginInsertRows({}, prefix, prefix + rowCount - 1); + + m_rowCounts[i] = rowCount; + + if (!m_initialized) { + initRoles(); + initRolesMapping(); + m_initialized = true; + } else { + initRolesMapping(i, model); + } + + endInsertRows(); + } + + connectModelSlots(i, model); + }); + + connect(source, &SourceModel::markerRoleValueChanged, this, + [this, source, i] + { + auto count = this->m_rowCounts[i]; + + if (count == 0) + return; + + auto prefix = this->countPrefix(i); + + emit this->dataChanged(this->index(prefix), + this->index(prefix + count - 1), + { this->m_markerRole }); + }); + } + + initAllModelsSlots(); + fetchRowCounts(); + + auto count = rowCountInternal(); + + if (count == 0) + return; + + beginInsertRows({}, 0, count - 1); + + initRoles(); + initRolesMapping(); + m_initialized = true; + + endInsertRows(); +} + +std::pair ConcatModel::sourceForIndex(int index) const +{ + if (index < 0) + return {}; + + int rowCount = 0; + + for (int i = 0; i < m_sources.size(); i++) { + const int subRowCount = m_rowCounts[i]; + + if (rowCount + subRowCount > index) + return {m_sources[i], index - rowCount}; + + rowCount += subRowCount; + } + + return {}; +} + + +void ConcatModel::initRoles() +{ + Q_ASSERT(m_roleNames.empty()); + Q_ASSERT(m_nameRoles.empty()); + + m_nameRoles.reserve(m_expectedRoles.size() + 1); + + for (auto& expectedRoleName : qAsConst(m_expectedRoles)) + m_nameRoles.try_emplace(expectedRoleName.toUtf8(), m_nameRoles.size()); + + for (auto sourceModel : qAsConst(m_sources)) { + auto model = sourceModel->model(); + + if (model == nullptr) + continue; + + auto roleNames = model->roleNames(); + + for (auto& role : roleNames) + m_nameRoles.try_emplace(role, m_nameRoles.size()); + } + + auto it = m_nameRoles.try_emplace(m_markerRoleName.toUtf8(), + m_nameRoles.size()).first; + m_markerRole = it->second; + + m_roleNames.reserve(m_nameRoles.size()); + + for (auto& [name, role] : m_nameRoles) + m_roleNames.insert(role, name); +} + +void ConcatModel::initRolesMapping(int index, QAbstractItemModel* model) +{ + Q_ASSERT(model != nullptr); + + auto roleNames = model->roleNames(); + auto rowCount = model->rowCount(); + + std::unordered_map fromSource; + std::unordered_map toSource; + + for (auto i = roleNames.cbegin(), end = roleNames.cend(); i != end; ++i) { + auto it = std::as_const(m_nameRoles).find(i.value()); + + if (it == m_nameRoles.cend()) + continue; + + auto globalRole = it->second; + + fromSource.insert({i.key(), globalRole}); + toSource.insert({globalRole, i.key()}); + } + + bool initialized = !roleNames.empty() || rowCount > 0; + + m_rolesMappingFromSource[index] = std::move(fromSource); + m_rolesMappingToSource[index] = std::move(toSource); + m_rolesMappingInitializationFlags[index] = initialized; +} + +void ConcatModel::initRolesMapping() +{ + Q_ASSERT(m_rolesMappingFromSource.empty()); + Q_ASSERT(m_rolesMappingToSource.empty()); + Q_ASSERT(m_rolesMappingInitializationFlags.empty()); + + m_rolesMappingFromSource.resize(m_sources.size()); + m_rolesMappingToSource.resize(m_sources.size()); + m_rolesMappingInitializationFlags.resize(m_sources.size(), false); + + for (auto i = 0; i < m_sources.size(); ++i) { + auto sourceModelWrapper = m_sources.at(i); + auto sourceModel = sourceModelWrapper->model(); + + if (sourceModel == nullptr) + continue; + + initRolesMapping(i, sourceModel); + } +} + +void ConcatModel::initAllModelsSlots() +{ + for (auto sourceIndex = 0; sourceIndex < m_sources.size(); ++sourceIndex) { + auto sourceModelWrapper = m_sources.at(sourceIndex); + auto sourceModel = sourceModelWrapper->model(); + + if (sourceModel) + connectModelSlots(sourceIndex, sourceModel); + } +} + +void ConcatModel::connectModelSlots(int index, QAbstractItemModel *model) +{ + connect(model, &QAbstractItemModel::rowsAboutToBeInserted, this, + [this, index](const QModelIndex &parent, int first, int last) + { + auto prefix = this->countPrefix(index); + this->beginInsertRows({}, first + prefix, last + prefix); + }); + + connect(model, &QAbstractItemModel::rowsInserted, this, + [this, model, index](const QModelIndex &parent, int first, int last) + { + m_rowCounts[index] += last - first + 1; + + if (!m_initialized) { + initRoles(); + initRolesMapping(); + m_initialized = true; + } else if (!m_rolesMappingInitializationFlags.at(index)) { + initRolesMapping(index, model); + } + + this->endInsertRows(); + }); + + connect(model, &QAbstractItemModel::rowsAboutToBeRemoved, this, + [this, index](const QModelIndex &parent, int first, int last) + { + auto prefix = this->countPrefix(index); + this->beginRemoveRows({}, first + prefix, last + prefix); + }); + + connect(model, &QAbstractItemModel::rowsRemoved, this, + [this, index](const QModelIndex &parent, int first, int last) + { + m_rowCounts[index] -= last - first + 1; + this->endRemoveRows(); + }); + + connect(model, &QAbstractItemModel::rowsAboutToBeMoved, this, + [this, index]( + const QModelIndex&, int sourceStart, int sourceEnd, + const QModelIndex&, int destinationRow) + { + auto prefix = this->countPrefix(index); + this->beginMoveRows({}, sourceStart + prefix, sourceEnd + prefix, + {}, destinationRow + prefix); + }); + + connect(model, &QAbstractItemModel::rowsMoved, this, + [this, index] + { + this->endMoveRows(); + }); + + connect(model, &QAbstractItemModel::layoutAboutToBeChanged, this, [this] + { + emit this->layoutAboutToBeChanged(); + }); + + connect(model, &QAbstractItemModel::layoutAboutToBeChanged, this, [this] + { + emit this->layoutChanged(); + }); + + connect(model, &QAbstractItemModel::modelAboutToBeReset, this, [this] + { + this->beginResetModel(); + }); + + connect(model, &QAbstractItemModel::modelReset, this, [this, model, index] + { + m_rowCounts[index] = model->rowCount(); + m_rolesMappingInitializationFlags[index] = false; + + this->endResetModel(); + }); + + connect(model, &QAbstractItemModel::dataChanged, this, + [this, index](auto& topLeft, const auto& bottomRight, auto& roles) + { + auto prefix = this->countPrefix(index); + auto rolesMapped = mapFromSourceRoles(index, roles); + + if (rolesMapped.empty()) + return; + + emit this->dataChanged(this->index(prefix + topLeft.row()), + this->index(prefix + bottomRight.row()), + rolesMapped); + }); +} + +void ConcatModel::disconnectModelSlots(QAbstractItemModel* model) +{ + Q_ASSERT(model != nullptr); + bool disconnected = disconnect(model, nullptr, this, nullptr); + Q_UNUSED(disconnected); + Q_ASSERT(disconnected); +} + +int ConcatModel::rowCountInternal() const +{ + return std::reduce(m_rowCounts.cbegin(), m_rowCounts.cend()); +} + +int ConcatModel::countPrefix(int sourceIndex) const +{ + Q_ASSERT(sourceIndex >= 0 && sourceIndex < m_sources.size()); + return std::reduce(m_rowCounts.cbegin(), m_rowCounts.cbegin() + sourceIndex); +} + +void ConcatModel::fetchRowCounts() +{ + m_rowCounts.resize(m_sources.size()); + + for (auto i = 0; i < m_sources.size(); ++i) { + auto sourceModelWrapper = m_sources.at(i); + auto sourceModel = sourceModelWrapper->model(); + + m_rowCounts[i] = (sourceModel == nullptr) ? 0 : sourceModel->rowCount(); + } +} + +QVector ConcatModel::mapFromSourceRoles( + int sourceIndex, const QVector& sourceRoles) const +{ + QVector mapped; + mapped.reserve(sourceRoles.size()); + + auto& mapping = m_rolesMappingFromSource[sourceIndex]; + + for (auto role : sourceRoles) { + auto it = mapping.find(role); + + if (it != mapping.end()) + mapped << it->second; + } + + return mapped; +} diff --git a/ui/StatusQ/src/plugin.cpp b/ui/StatusQ/src/plugin.cpp index dcd73ea9b7..61305204af 100644 --- a/ui/StatusQ/src/plugin.cpp +++ b/ui/StatusQ/src/plugin.cpp @@ -4,6 +4,7 @@ #include #include "StatusQ/QClipboardProxy.h" +#include "StatusQ/concatmodel.h" #include "StatusQ/leftjoinmodel.h" #include "StatusQ/modelutilsinternal.h" #include "StatusQ/permissionutilsinternal.h" @@ -35,6 +36,8 @@ public: qmlRegisterType("StatusQ.Models", 0, 1, "ManageTokensController"); qmlRegisterType("StatusQ.Models", 0, 1, "ManageTokensModel"); + qmlRegisterType("StatusQ", 0, 1, "SourceModel"); + qmlRegisterType("StatusQ", 0, 1, "ConcatModel"); qmlRegisterType("StatusQ", 0, 1, "LeftJoinModel"); qmlRegisterType("StatusQ", 0, 1, "SubmodelProxyModel"); qmlRegisterType("StatusQ", 0, 1, "RoleRename"); diff --git a/ui/StatusQ/tests/CMakeLists.txt b/ui/StatusQ/tests/CMakeLists.txt index c6b5f60ea3..230ba055f7 100644 --- a/ui/StatusQ/tests/CMakeLists.txt +++ b/ui/StatusQ/tests/CMakeLists.txt @@ -64,3 +64,7 @@ add_test(NAME SingleRoleAggregatorTest COMMAND SingleRoleAggregatorTest) add_executable(SumAggregatorTest tst_SumAggregator.cpp) target_link_libraries(SumAggregatorTest PRIVATE Qt5::Test StatusQ) add_test(NAME SumAggregatorTest COMMAND SumAggregatorTest) + +add_executable(ConcatModelTest tst_ConcatModel.cpp) +target_link_libraries(ConcatModelTest PRIVATE Qt5::Qml Qt5::Test StatusQ) +add_test(NAME ConcatModelTest COMMAND ConcatModelTest) diff --git a/ui/StatusQ/tests/tst_ConcatModel.cpp b/ui/StatusQ/tests/tst_ConcatModel.cpp new file mode 100644 index 0000000000..f929c39421 --- /dev/null +++ b/ui/StatusQ/tests/tst_ConcatModel.cpp @@ -0,0 +1,1875 @@ +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include + +namespace { + +class ListModelWrapper { + +public: + explicit ListModelWrapper(QQmlEngine& engine, const QString& content = "[]") + { + QQmlComponent component(&engine); + auto componentBody = QStringLiteral(R"( + import QtQml 2.15 + import QtQml.Models 2.15 + + ListModel { + Component.onCompleted: { + const content = %1 + + if (content.length) + append(content) + } + } + )").arg(content); + + component.setData(componentBody.toUtf8(), {}); + + m_model.reset(qobject_cast( + component.create(engine.rootContext()))); + } + + explicit ListModelWrapper(QQmlEngine& engine, const QJsonArray& content) + : ListModelWrapper(engine, QJsonDocument(content).toJson()) + { + } + + QAbstractItemModel* model() const + { + return m_model.get(); + } + + int count() const + { + return m_model->rowCount(); + } + + int role(const QString& roleName) + { + QHash roleNames = m_model->roleNames(); + QList roles = roleNames.keys(roleName.toUtf8()); + + return roles.length() != 1 ? -1 : roles.first(); + } + + void set(int index, const QJsonObject& dict) + { + QString jsonDict = QJsonDocument(dict).toJson(); + runExpression(QString("set(%1, %2)").arg(index).arg(jsonDict)); + } + + void setProperty(int index, const QString& property, const QVariant& value) + { + QString valueStr = value.type() == QVariant::String + ? QString("'%1'").arg(value.toString()) + : value.toString(); + + runExpression(QString("setProperty(%1, '%2', %3)").arg(index) + .arg(property, valueStr)); + } + + QVariant get(int index, const QString& roleName) + { + auto role = this->role(roleName); + + if (role == -1) + return {}; + + return m_model->data(m_model->index(index, 0), role); + } + + void insert(int index, const QJsonObject& dict) { + QString jsonDict = QJsonDocument(dict).toJson(); + runExpression(QString("insert(%1, %2)").arg(index).arg(jsonDict)); + } + + void append(const QJsonArray& data) { + QString jsonData = QJsonDocument(data).toJson(); + runExpression(QString("append(%1)").arg(jsonData)); + } + + void clear() { + runExpression(QString("append()")); + } + + void remove(int index, int count = 1) { + runExpression(QString("remove(%1, %2)").arg(QString::number(index), + QString::number(count))); + } + + void move(int from, int to, int n = 1) { + runExpression(QString("move(%1, %2, %3)").arg(QString::number(from), + QString::number(to), + QString::number(n))); + } + +private: + void runExpression(const QString& expression) + { + QQmlExpression(QQmlEngine::contextForObject(m_model.get()), + m_model.get(), expression).evaluate(); + } + + std::unique_ptr m_model; +}; + +} // unnamed namespace + +class TestConcatModel: public QObject +{ + Q_OBJECT + + int roleForName(const QHash& roles, const QByteArray& name) const + { + auto keys = roles.keys(name); + + if (keys.empty()) + return -1; + + return keys.first(); + } + +private slots: + void initializationTest() + { + QQmlEngine engine; + ConcatModel model; + + ListModelWrapper sourceModel1(engine, QJsonArray { + QJsonObject {{ "key", 1}, { "balance", 11 }, { "name", "n1" }}, + QJsonObject {{ "key", 2},{ "balance", 12 }, { "name", "n2" }}, + QJsonObject {{ "key", 3},{ "balance", 13}, { "name", "n3" }}, + }); + + ListModelWrapper sourceModel2(engine, QJsonArray {}); + + ListModelWrapper sourceModel3(engine, QJsonArray { + QJsonObject {{ "balance", 14 }, { "name", "n4" }, { "color", "red"}}, + QJsonObject {{ "balance", 15 }, { "name", "n5" }, { "color", "green"}}, + QJsonObject {{ "balance", 16 }, { "name", "n6" }, { "color", "blue"}}, + QJsonObject {{ "balance", 17 }, { "name", "n7" }, { "color", "pink"}}, + }); + + ListModelWrapper sourceModel4(engine, QJsonArray {}); + + QQmlListProperty sources = model.sources(); + + SourceModel source1; + source1.setModel(sourceModel1.model()); + + SourceModel source2; + source2.setModel(sourceModel2.model()); + + SourceModel source3; + source3.setModel(sourceModel3.model()); + + SourceModel source4; + source4.setModel(sourceModel4.model()); + + sources.append(&sources, &source1); + sources.append(&sources, &source2); + sources.append(&sources, &source3); + sources.append(&sources, &source4); + + QCOMPARE(model.rowCount(), 0); + QCOMPARE(model.roleNames(), {}); + QCOMPARE(model.index(0, 0).isValid(), false); + + model.componentComplete(); + + auto roles = model.roleNames(); + + QCOMPARE(model.rowCount(), 7); + QCOMPARE(roles.size(), 5); + + QCOMPARE(model.data(model.index(0, 0), roleForName(roles, "key")), 1); + QCOMPARE(model.data(model.index(1, 0), roleForName(roles, "key")), 2); + QCOMPARE(model.data(model.index(2, 0), roleForName(roles, "key")), 3); + QCOMPARE(model.data(model.index(3, 0), roleForName(roles, "key")), {}); + QCOMPARE(model.data(model.index(4, 0), roleForName(roles, "key")), {}); + QCOMPARE(model.data(model.index(5, 0), roleForName(roles, "key")), {}); + QCOMPARE(model.data(model.index(6, 0), roleForName(roles, "key")), {}); + + QCOMPARE(model.data(model.index(0, 0), roleForName(roles, "balance")), 11); + QCOMPARE(model.data(model.index(1, 0), roleForName(roles, "balance")), 12); + QCOMPARE(model.data(model.index(2, 0), roleForName(roles, "balance")), 13); + QCOMPARE(model.data(model.index(3, 0), roleForName(roles, "balance")), 14); + QCOMPARE(model.data(model.index(4, 0), roleForName(roles, "balance")), 15); + QCOMPARE(model.data(model.index(5, 0), roleForName(roles, "balance")), 16); + QCOMPARE(model.data(model.index(6, 0), roleForName(roles, "balance")), 17); + + QCOMPARE(model.data(model.index(0, 0), roleForName(roles, "name")), "n1"); + QCOMPARE(model.data(model.index(1, 0), roleForName(roles, "name")), "n2"); + QCOMPARE(model.data(model.index(2, 0), roleForName(roles, "name")), "n3"); + QCOMPARE(model.data(model.index(3, 0), roleForName(roles, "name")), "n4"); + QCOMPARE(model.data(model.index(4, 0), roleForName(roles, "name")), "n5"); + QCOMPARE(model.data(model.index(5, 0), roleForName(roles, "name")), "n6"); + QCOMPARE(model.data(model.index(6, 0), roleForName(roles, "name")), "n7"); + + QCOMPARE(model.data(model.index(0, 0), roleForName(roles, "color")), {}); + QCOMPARE(model.data(model.index(1, 0), roleForName(roles, "color")), {}); + QCOMPARE(model.data(model.index(2, 0), roleForName(roles, "color")), {}); + QCOMPARE(model.data(model.index(3, 0), roleForName(roles, "color")), "red"); + QCOMPARE(model.data(model.index(4, 0), roleForName(roles, "color")), "green"); + QCOMPARE(model.data(model.index(5, 0), roleForName(roles, "color")), "blue"); + QCOMPARE(model.data(model.index(6, 0), roleForName(roles, "color")), "pink"); + + QCOMPARE(model.data(model.index(0, 0), roleForName(roles, "whichModel")), ""); + QCOMPARE(model.data(model.index(1, 0), roleForName(roles, "whichModel")), ""); + QCOMPARE(model.data(model.index(2, 0), roleForName(roles, "whichModel")), ""); + QCOMPARE(model.data(model.index(3, 0), roleForName(roles, "whichModel")), ""); + QCOMPARE(model.data(model.index(4, 0), roleForName(roles, "whichModel")), ""); + QCOMPARE(model.data(model.index(5, 0), roleForName(roles, "whichModel")), ""); + QCOMPARE(model.data(model.index(6, 0), roleForName(roles, "whichModel")), ""); + + // out of bounds + QCOMPARE(model.index(-1, 0).isValid(), false); + QCOMPARE(model.index(7, 0).isValid(), false); + + auto roleKeys = roles.keys(); + auto roleOutOfRange = *std::max_element(roleKeys.begin(), + roleKeys.end()) + 1; + QCOMPARE(model.data(model.index(0, 0), roleOutOfRange), {}); + + // getting source model and source model row + QCOMPARE(model.sourceModel(0), sourceModel1.model()); + QCOMPARE(model.sourceModel(1), sourceModel1.model()); + QCOMPARE(model.sourceModel(2), sourceModel1.model()); + QCOMPARE(model.sourceModel(3), sourceModel3.model()); + QCOMPARE(model.sourceModel(4), sourceModel3.model()); + QCOMPARE(model.sourceModel(5), sourceModel3.model()); + QCOMPARE(model.sourceModel(6), sourceModel3.model()); + QCOMPARE(model.sourceModel(7), nullptr); + QCOMPARE(model.sourceModel(-1), nullptr); + + QCOMPARE(model.sourceModelRow(0), 0); + QCOMPARE(model.sourceModelRow(1), 1); + QCOMPARE(model.sourceModelRow(2), 2); + QCOMPARE(model.sourceModelRow(3), 0); + QCOMPARE(model.sourceModelRow(4), 1); + QCOMPARE(model.sourceModelRow(5), 2); + QCOMPARE(model.sourceModelRow(6), 3); + QCOMPARE(model.sourceModelRow(7), -1); + QCOMPARE(model.sourceModelRow(-1), -1); + + // getting row by source model source model row + QCOMPARE(model.fromSourceRow(nullptr, 0), -1); + + QCOMPARE(model.fromSourceRow(sourceModel1.model(), 0), 0); + QCOMPARE(model.fromSourceRow(sourceModel1.model(), 1), 1); + QCOMPARE(model.fromSourceRow(sourceModel1.model(), 2), 2); + QCOMPARE(model.fromSourceRow(sourceModel1.model(), 3), -1); + QCOMPARE(model.fromSourceRow(sourceModel1.model(), -1), -1); + QCOMPARE(model.fromSourceRow(sourceModel2.model(), 0), -1); + QCOMPARE(model.fromSourceRow(sourceModel3.model(), 0), 3); + QCOMPARE(model.fromSourceRow(sourceModel3.model(), 1), 4); + QCOMPARE(model.fromSourceRow(sourceModel3.model(), 2), 5); + QCOMPARE(model.fromSourceRow(sourceModel3.model(), 3), 6); + QCOMPARE(model.fromSourceRow(sourceModel3.model(), 4), -1); + } + + void dataChangeTest() + { + QQmlEngine engine; + ConcatModel model; + + ListModelWrapper sourceModel1(engine, QJsonArray { + QJsonObject {{ "key", 1}, { "balance", 11 }, { "name", "n1" }}, + QJsonObject {{ "key", 2},{ "balance", 12 }, { "name", "n2" }}, + QJsonObject {{ "key", 3},{ "balance", 13}, { "name", "n3" }}, + }); + + ListModelWrapper sourceModel2(engine, QJsonArray {}); + + ListModelWrapper sourceModel3(engine, QJsonArray { + QJsonObject {{ "balance", 14 }, { "name", "n4" }, { "color", "red"}}, + QJsonObject {{ "balance", 15 }, { "name", "n5" }, { "color", "green"}}, + QJsonObject {{ "balance", 16 }, { "name", "n6" }, { "color", "blue"}}, + QJsonObject {{ "balance", 17 }, { "name", "n7" }, { "color", "pink"}}, + }); + + ListModelWrapper sourceModel4(engine, QJsonArray {}); + + QQmlListProperty sources = model.sources(); + + SourceModel source1; + source1.setModel(sourceModel1.model()); + + SourceModel source2; + source2.setModel(sourceModel2.model()); + + SourceModel source3; + source3.setModel(sourceModel3.model()); + + SourceModel source4; + source4.setModel(sourceModel4.model()); + + sources.append(&sources, &source1); + sources.append(&sources, &source2); + sources.append(&sources, &source3); + sources.append(&sources, &source4); + + QCOMPARE(model.rowCount(), 0); + QCOMPARE(model.roleNames(), {}); + QCOMPARE(model.index(0, 0).isValid(), false); + + model.componentComplete(); + + auto roles = model.roleNames(); + + // first non-empty source model modifications + { + QSignalSpy dataChangedSpy(&model, &ConcatModel::dataChanged); + sourceModel1.setProperty(0, "key", 21); + QCOMPARE(dataChangedSpy.count(), 1); + QCOMPARE(dataChangedSpy.at(0).at(0), model.index(0, 0)); + QCOMPARE(dataChangedSpy.at(0).at(1), model.index(0, 0)); + QCOMPARE(dataChangedSpy.at(0).at(2).value>(), + { roleForName(roles, "key") }); + + QCOMPARE(model.data(model.index(0, 0), roleForName(roles, "key")), 21); + } + { + QSignalSpy dataChangedSpy(&model, &ConcatModel::dataChanged); + sourceModel1.setProperty(1, "balance", 22); + QCOMPARE(dataChangedSpy.count(), 1); + QCOMPARE(dataChangedSpy.at(0).at(0), model.index(1, 0)); + QCOMPARE(dataChangedSpy.at(0).at(1), model.index(1, 0)); + QCOMPARE(dataChangedSpy.at(0).at(2).value>(), + { roleForName(roles, "balance") }); + + QCOMPARE(model.data(model.index(1, 0), roleForName(roles, "balance")), 22); + } + { + QSignalSpy dataChangedSpy(&model, &ConcatModel::dataChanged); + sourceModel1.setProperty(2, "name", "n13"); + QCOMPARE(dataChangedSpy.count(), 1); + QCOMPARE(dataChangedSpy.at(0).at(0), model.index(2, 0)); + QCOMPARE(dataChangedSpy.at(0).at(1), model.index(2, 0)); + QCOMPARE(dataChangedSpy.at(0).at(2).value>(), + { roleForName(roles, "name") }); + + QCOMPARE(model.data(model.index(2, 0), roleForName(roles, "name")), "n13"); + } + + // second non-empty source model modifications + { + QSignalSpy dataChangedSpy(&model, &ConcatModel::dataChanged); + sourceModel3.setProperty(0, "balance", 24); + QCOMPARE(dataChangedSpy.count(), 1); + QCOMPARE(dataChangedSpy.at(0).at(0), model.index(3, 0)); + QCOMPARE(dataChangedSpy.at(0).at(1), model.index(3, 0)); + QCOMPARE(dataChangedSpy.at(0).at(2).value>(), + { roleForName(roles, "balance") }); + + QCOMPARE(model.data(model.index(3, 0), roleForName(roles, "balance")), 24); + } + { + QSignalSpy dataChangedSpy(&model, &ConcatModel::dataChanged); + sourceModel3.setProperty(1, "name", "n25"); + QCOMPARE(dataChangedSpy.count(), 1); + QCOMPARE(dataChangedSpy.at(0).at(0), model.index(4, 0)); + QCOMPARE(dataChangedSpy.at(0).at(1), model.index(4, 0)); + QCOMPARE(dataChangedSpy.at(0).at(2).value>(), + { roleForName(roles, "name") }); + + QCOMPARE(model.data(model.index(4, 0), roleForName(roles, "name")), "n25"); + } + { + QSignalSpy dataChangedSpy(&model, &ConcatModel::dataChanged); + sourceModel3.setProperty(2, "color", "orange"); + QCOMPARE(dataChangedSpy.count(), 1); + QCOMPARE(dataChangedSpy.at(0).at(0), model.index(5, 0)); + QCOMPARE(dataChangedSpy.at(0).at(1), model.index(5, 0)); + QCOMPARE(dataChangedSpy.at(0).at(2).value>(), + { roleForName(roles, "color") }); + + QCOMPARE(model.data(model.index(5, 0), roleForName(roles, "color")), "orange"); + } + + QCOMPARE(model.data(model.index(0, 0), roleForName(roles, "key")), 21); + QCOMPARE(model.data(model.index(1, 0), roleForName(roles, "key")), 2); + QCOMPARE(model.data(model.index(2, 0), roleForName(roles, "key")), 3); + QCOMPARE(model.data(model.index(3, 0), roleForName(roles, "key")), {}); + QCOMPARE(model.data(model.index(4, 0), roleForName(roles, "key")), {}); + QCOMPARE(model.data(model.index(5, 0), roleForName(roles, "key")), {}); + QCOMPARE(model.data(model.index(6, 0), roleForName(roles, "key")), {}); + + QCOMPARE(model.data(model.index(0, 0), roleForName(roles, "balance")), 11); + QCOMPARE(model.data(model.index(1, 0), roleForName(roles, "balance")), 22); + QCOMPARE(model.data(model.index(2, 0), roleForName(roles, "balance")), 13); + QCOMPARE(model.data(model.index(3, 0), roleForName(roles, "balance")), 24); + QCOMPARE(model.data(model.index(4, 0), roleForName(roles, "balance")), 15); + QCOMPARE(model.data(model.index(5, 0), roleForName(roles, "balance")), 16); + QCOMPARE(model.data(model.index(6, 0), roleForName(roles, "balance")), 17); + + QCOMPARE(model.data(model.index(0, 0), roleForName(roles, "name")), "n1"); + QCOMPARE(model.data(model.index(1, 0), roleForName(roles, "name")), "n2"); + QCOMPARE(model.data(model.index(2, 0), roleForName(roles, "name")), "n13"); + QCOMPARE(model.data(model.index(3, 0), roleForName(roles, "name")), "n4"); + QCOMPARE(model.data(model.index(4, 0), roleForName(roles, "name")), "n25"); + QCOMPARE(model.data(model.index(5, 0), roleForName(roles, "name")), "n6"); + QCOMPARE(model.data(model.index(6, 0), roleForName(roles, "name")), "n7"); + + QCOMPARE(model.data(model.index(0, 0), roleForName(roles, "color")), {}); + QCOMPARE(model.data(model.index(1, 0), roleForName(roles, "color")), {}); + QCOMPARE(model.data(model.index(2, 0), roleForName(roles, "color")), {}); + QCOMPARE(model.data(model.index(3, 0), roleForName(roles, "color")), "red"); + QCOMPARE(model.data(model.index(4, 0), roleForName(roles, "color")), "green"); + QCOMPARE(model.data(model.index(5, 0), roleForName(roles, "color")), "orange"); + QCOMPARE(model.data(model.index(6, 0), roleForName(roles, "color")), "pink"); + + QCOMPARE(model.data(model.index(0, 0), roleForName(roles, "whichModel")), ""); + QCOMPARE(model.data(model.index(1, 0), roleForName(roles, "whichModel")), ""); + QCOMPARE(model.data(model.index(2, 0), roleForName(roles, "whichModel")), ""); + QCOMPARE(model.data(model.index(3, 0), roleForName(roles, "whichModel")), ""); + QCOMPARE(model.data(model.index(4, 0), roleForName(roles, "whichModel")), ""); + QCOMPARE(model.data(model.index(5, 0), roleForName(roles, "whichModel")), ""); + QCOMPARE(model.data(model.index(6, 0), roleForName(roles, "whichModel")), ""); + } + + void dataChangeOnNotTrackedRoleTest() + { + QQmlEngine engine; + ConcatModel model; + + ListModelWrapper sourceModel1(engine, QJsonArray { + QJsonObject {{ "key", 1}, { "balance", 11 }, { "name", "n1" }}, + QJsonObject {{ "key", 2},{ "balance", 12 }, { "name", "n2" }}, + QJsonObject {{ "key", 3},{ "balance", 13}, { "name", "n3" }}, + }); + + ListModelWrapper sourceModel2(engine, QJsonArray {}); + + QQmlListProperty sources = model.sources(); + + SourceModel source1, source2; + source1.setModel(sourceModel1.model()); + source2.setModel(sourceModel2.model()); + + sources.append(&sources, &source1); + sources.append(&sources, &source2); + + QCOMPARE(model.rowCount(), 0); + QCOMPARE(model.roleNames(), {}); + QCOMPARE(model.index(0, 0).isValid(), false); + + model.componentComplete(); + + QCOMPARE(model.rowCount(), 3); + + sourceModel2.append(QJsonArray { + QJsonObject {{ "someRole", 1}}, QJsonObject {{ "someRole", 2}}, + QJsonObject {{ "someRole", 3}}, QJsonObject {{ "someRole", 4}} + }); + + QCOMPARE(model.rowCount(), 7); + + QSignalSpy dataChangedSpy(&model, &ConcatModel::dataChanged); + sourceModel2.setProperty(0, "someRole", 42); + QCOMPARE(dataChangedSpy.count(), 0); + } + + void dataInsertionTest() + { + QQmlEngine engine; + ConcatModel model; + + ListModelWrapper sourceModel1(engine, QJsonArray { + QJsonObject {{ "key", 1}, { "balance", 11 }, { "name", "n1" }}, + QJsonObject {{ "key", 2},{ "balance", 12 }, { "name", "n2" }}, + QJsonObject {{ "key", 3},{ "balance", 13}, { "name", "n3" }}, + }); + + ListModelWrapper sourceModel2(engine, QJsonArray {}); + + ListModelWrapper sourceModel3(engine, QJsonArray { + QJsonObject {{ "balance", 14 }, { "name", "n4" }, { "color", "red"}}, + QJsonObject {{ "balance", 15 }, { "name", "n5" }, { "color", "green"}}, + QJsonObject {{ "balance", 16 }, { "name", "n6" }, { "color", "blue"}}, + QJsonObject {{ "balance", 17 }, { "name", "n7" }, { "color", "pink"}}, + }); + + ListModelWrapper sourceModel4(engine, QJsonArray {}); + + QQmlListProperty sources = model.sources(); + + SourceModel source1; + source1.setModel(sourceModel1.model()); + + SourceModel source2; + source2.setModel(sourceModel2.model()); + + SourceModel source3; + source3.setModel(sourceModel3.model()); + + SourceModel source4; + source4.setModel(sourceModel4.model()); + + sources.append(&sources, &source1); + sources.append(&sources, &source2); + sources.append(&sources, &source3); + sources.append(&sources, &source4); + + QCOMPARE(model.rowCount(), 0); + QCOMPARE(model.roleNames(), {}); + QCOMPARE(model.index(0, 0).isValid(), false); + + model.componentComplete(); + + auto roles = model.roleNames(); + + // inserting into first model + { + QSignalSpy rowsInsertedSpy(&model, &ConcatModel::rowsInserted); + + sourceModel1.insert(0, QJsonObject { + { "key", 200}, { "balance", 300 }, { "name", "n200" } + }); + + QCOMPARE(rowsInsertedSpy.count(), 1); + QCOMPARE(rowsInsertedSpy.at(0).at(0), QModelIndex{}); + QCOMPARE(rowsInsertedSpy.at(0).at(1), 0); + QCOMPARE(rowsInsertedSpy.at(0).at(2), 0); + + QCOMPARE(model.rowCount(), 8); + + QCOMPARE(model.data(model.index(0, 0), roleForName(roles, "key")), 200); + QCOMPARE(model.data(model.index(0, 0), roleForName(roles, "balance")), 300); + QCOMPARE(model.data(model.index(0, 0), roleForName(roles, "name")), "n200"); + QCOMPARE(model.data(model.index(0, 0), roleForName(roles, "color")), {}); + QCOMPARE(model.data(model.index(0, 0), roleForName(roles, "whichModel")), ""); + } + { + QSignalSpy rowsInsertedSpy(&model, &ConcatModel::rowsInserted); + + sourceModel1.insert(2, QJsonObject {{ "key", 201}, { "balance", 301 }, { "name", "n201" }}); + + QCOMPARE(rowsInsertedSpy.count(), 1); + QCOMPARE(rowsInsertedSpy.at(0).at(0), QModelIndex{}); + QCOMPARE(rowsInsertedSpy.at(0).at(1), 2); + QCOMPARE(rowsInsertedSpy.at(0).at(2), 2); + + QCOMPARE(model.rowCount(), 9); + + QCOMPARE(model.data(model.index(2, 0), roleForName(roles, "key")), 201); + QCOMPARE(model.data(model.index(2, 0), roleForName(roles, "balance")), 301); + QCOMPARE(model.data(model.index(2, 0), roleForName(roles, "name")), "n201"); + QCOMPARE(model.data(model.index(2, 0), roleForName(roles, "color")), {}); + QCOMPARE(model.data(model.index(2, 0), roleForName(roles, "whichModel")), ""); + } + + // inserting into second, empty model + { + QSignalSpy rowsInsertedSpy(&model, &ConcatModel::rowsInserted); + + sourceModel2.insert(0, QJsonObject {{ "key", 202}, { "balance", 302 }, { "name", "n202" }}); + + QCOMPARE(rowsInsertedSpy.count(), 1); + QCOMPARE(rowsInsertedSpy.at(0).at(0), QModelIndex{}); + QCOMPARE(rowsInsertedSpy.at(0).at(1), 5); + QCOMPARE(rowsInsertedSpy.at(0).at(2), 5); + + QCOMPARE(model.rowCount(), 10); + + QCOMPARE(model.data(model.index(5, 0), roleForName(roles, "key")), 202); + QCOMPARE(model.data(model.index(5, 0), roleForName(roles, "balance")), 302); + QCOMPARE(model.data(model.index(5, 0), roleForName(roles, "name")), "n202"); + QCOMPARE(model.data(model.index(5, 0), roleForName(roles, "color")), {}); + QCOMPARE(model.data(model.index(5, 0), roleForName(roles, "whichModel")), ""); + } + + // inserting into third + { + QSignalSpy rowsInsertedSpy(&model, &ConcatModel::rowsInserted); + + sourceModel3.insert(2, QJsonObject { + { "balance", 303 }, { "name", "n203" }, { "color", "brown" } + }); + + QCOMPARE(rowsInsertedSpy.count(), 1); + QCOMPARE(rowsInsertedSpy.at(0).at(0), QModelIndex{}); + QCOMPARE(rowsInsertedSpy.at(0).at(1), 8); + QCOMPARE(rowsInsertedSpy.at(0).at(2), 8); + + QCOMPARE(model.rowCount(), 11); + + QCOMPARE(model.data(model.index(8, 0), roleForName(roles, "key")), {}); + QCOMPARE(model.data(model.index(8, 0), roleForName(roles, "balance")), 303); + QCOMPARE(model.data(model.index(8, 0), roleForName(roles, "name")), "n203"); + QCOMPARE(model.data(model.index(8, 0), roleForName(roles, "color")), "brown"); + QCOMPARE(model.data(model.index(8, 0), roleForName(roles, "whichModel")), ""); + } + + // inserting into forth, empty model + { + QSignalSpy rowsInsertedSpy(&model, &ConcatModel::rowsInserted); + + sourceModel4.insert(0, QJsonObject { + { "key", 204 }, { "balance", 304 }, { "name", "n204" }, { "color", "black" } + }); + + QCOMPARE(rowsInsertedSpy.count(), 1); + QCOMPARE(rowsInsertedSpy.at(0).at(0), QModelIndex{}); + QCOMPARE(rowsInsertedSpy.at(0).at(1), 11); + QCOMPARE(rowsInsertedSpy.at(0).at(2), 11); + + QCOMPARE(model.rowCount(), 12); + + QCOMPARE(model.data(model.index(11, 0), roleForName(roles, "key")), 204); + QCOMPARE(model.data(model.index(11, 0), roleForName(roles, "balance")), 304); + QCOMPARE(model.data(model.index(11, 0), roleForName(roles, "name")), "n204"); + QCOMPARE(model.data(model.index(11, 0), roleForName(roles, "color")), "black"); + QCOMPARE(model.data(model.index(11, 0), roleForName(roles, "whichModel")), ""); + } + + // inserting multiple items (first model) + { + QSignalSpy rowsInsertedSpy(&model, &ConcatModel::rowsInserted); + + sourceModel1.append(QJsonArray { + QJsonObject {{ "key", 205}, { "balance", 305 }, { "name", "n205" }}, + QJsonObject {{ "key", 206},{ "balance", 306 }, { "name", "n206" }} + }); + + QCOMPARE(rowsInsertedSpy.count(), 1); + QCOMPARE(rowsInsertedSpy.at(0).at(0), QModelIndex{}); + QCOMPARE(rowsInsertedSpy.at(0).at(1), 5); + QCOMPARE(rowsInsertedSpy.at(0).at(2), 6); + + QCOMPARE(model.rowCount(), 14); + + QCOMPARE(model.data(model.index(5, 0), roleForName(roles, "key")), 205); + QCOMPARE(model.data(model.index(5, 0), roleForName(roles, "balance")), 305); + QCOMPARE(model.data(model.index(5, 0), roleForName(roles, "name")), "n205"); + QCOMPARE(model.data(model.index(5, 0), roleForName(roles, "color")), {}); + QCOMPARE(model.data(model.index(5, 0), roleForName(roles, "whichModel")), ""); + + QCOMPARE(model.data(model.index(6, 0), roleForName(roles, "key")), 206); + QCOMPARE(model.data(model.index(6, 0), roleForName(roles, "balance")), 306); + QCOMPARE(model.data(model.index(6, 0), roleForName(roles, "name")), "n206"); + QCOMPARE(model.data(model.index(6, 0), roleForName(roles, "color")), {}); + QCOMPARE(model.data(model.index(6, 0), roleForName(roles, "whichModel")), ""); + } + } + + void dataInsertionToEmptyModelTest() + { + QQmlEngine engine; + ConcatModel model; + + ListModelWrapper sourceModel1(engine); + ListModelWrapper sourceModel2(engine); + + QQmlListProperty sources = model.sources(); + + SourceModel source1; + source1.setModel(sourceModel1.model()); + + SourceModel source2; + source2.setModel(sourceModel2.model()); + + sources.append(&sources, &source1); + sources.append(&sources, &source2); + + QCOMPARE(model.rowCount(), 0); + QCOMPARE(model.roleNames(), {}); + QCOMPARE(model.index(0, 0).isValid(), false); + + model.componentComplete(); + + QCOMPARE(model.roleNames(), {}); + + { + QSignalSpy rowsInsertedSpy(&model, &ConcatModel::rowsInserted); + + sourceModel1.append(QJsonArray { + QJsonObject {{ "key", 1}, { "balance", 11 }, { "name", "n1" }}, + QJsonObject {{ "key", 2},{ "balance", 12 }, { "name", "n2" }} + }); + + QCOMPARE(rowsInsertedSpy.count(), 1); + QCOMPARE(rowsInsertedSpy.at(0).at(0), QModelIndex{}); + QCOMPARE(rowsInsertedSpy.at(0).at(1), 0); + QCOMPARE(rowsInsertedSpy.at(0).at(2), 1); + + QCOMPARE(model.rowCount(), 2); + + auto roles = model.roleNames(); + QCOMPARE(roles.size(), 4); + + QCOMPARE(model.data(model.index(0, 0), roleForName(roles, "key")), 1); + QCOMPARE(model.data(model.index(0, 0), roleForName(roles, "balance")), 11); + QCOMPARE(model.data(model.index(0, 0), roleForName(roles, "name")), "n1"); + QCOMPARE(model.data(model.index(0, 0), roleForName(roles, "whichModel")), ""); + + QCOMPARE(model.data(model.index(1, 0), roleForName(roles, "key")), 2); + QCOMPARE(model.data(model.index(1, 0), roleForName(roles, "balance")), 12); + QCOMPARE(model.data(model.index(1, 0), roleForName(roles, "name")), "n2"); + QCOMPARE(model.data(model.index(1, 0), roleForName(roles, "whichModel")), ""); + } + { + QSignalSpy rowsInsertedSpy(&model, &ConcatModel::rowsInserted); + + sourceModel2.append(QJsonArray { + QJsonObject {{ "key", 3}, { "color", "red" }}, + QJsonObject {{ "key", 4}, { "color", "blue" }} + }); + + QCOMPARE(rowsInsertedSpy.count(), 1); + QCOMPARE(rowsInsertedSpy.at(0).at(0), QModelIndex{}); + QCOMPARE(rowsInsertedSpy.at(0).at(1), 2); + QCOMPARE(rowsInsertedSpy.at(0).at(2), 3); + + QCOMPARE(model.rowCount(), 4); + + auto roles = model.roleNames(); + QCOMPARE(roles.size(), 4); + + QCOMPARE(model.data(model.index(0, 0), roleForName(roles, "key")), 1); + QCOMPARE(model.data(model.index(0, 0), roleForName(roles, "balance")), 11); + QCOMPARE(model.data(model.index(0, 0), roleForName(roles, "name")), "n1"); + QCOMPARE(model.data(model.index(0, 0), roleForName(roles, "whichModel")), ""); + + QCOMPARE(model.data(model.index(1, 0), roleForName(roles, "key")), 2); + QCOMPARE(model.data(model.index(1, 0), roleForName(roles, "balance")), 12); + QCOMPARE(model.data(model.index(1, 0), roleForName(roles, "name")), "n2"); + QCOMPARE(model.data(model.index(1, 0), roleForName(roles, "whichModel")), ""); + + QCOMPARE(model.data(model.index(2, 0), roleForName(roles, "key")), 3); + QCOMPARE(model.data(model.index(2, 0), roleForName(roles, "balance")), {}); + QCOMPARE(model.data(model.index(2, 0), roleForName(roles, "name")), {}); + QCOMPARE(model.data(model.index(2, 0), roleForName(roles, "whichModel")), ""); + + QCOMPARE(model.data(model.index(3, 0), roleForName(roles, "key")), 4); + QCOMPARE(model.data(model.index(3, 0), roleForName(roles, "balance")), {}); + QCOMPARE(model.data(model.index(3, 0), roleForName(roles, "name")), {}); + QCOMPARE(model.data(model.index(3, 0), roleForName(roles, "whichModel")), ""); + } + } + + void deferredNonEmptyModelSettingTest() + { + QQmlEngine engine; + ConcatModel model; + + ListModelWrapper sourceModel1(engine, QJsonArray { + QJsonObject {{ "key", 1}, { "color", "red" }}, + QJsonObject {{ "key", 2}, { "color", "blue" }} + }); + ListModelWrapper sourceModel2(engine, QJsonArray { + QJsonObject {{ "key", 3}, { "color", "orange" }}, + QJsonObject {{ "key", 4}, { "color", "green" }} + }); + + QQmlListProperty sources = model.sources(); + + SourceModel source1, source2; + sources.append(&sources, &source1); + sources.append(&sources, &source2); + + QCOMPARE(model.rowCount(), 0); + QCOMPARE(model.roleNames(), {}); + QCOMPARE(model.index(0, 0).isValid(), false); + + model.componentComplete(); + + QCOMPARE(model.rowCount(), 0); + QCOMPARE(model.roleNames(), {}); + + { + QSignalSpy rowsAboutToBeInsertedSpy( + &model, &ConcatModel::rowsAboutToBeInserted); + QSignalSpy rowsInsertedSpy(&model, &ConcatModel::rowsInserted); + + // checking validity inside rowsAboutToBeInserted signal + { + QObject context; + connect(&model, &ConcatModel::rowsAboutToBeInserted, &context, + [&model] { QCOMPARE(model.rowCount(), 0); }); + + source1.setModel(sourceModel1.model()); + } + + QCOMPARE(rowsAboutToBeInsertedSpy.count(), 1); + QCOMPARE(rowsAboutToBeInsertedSpy.at(0).at(0), QModelIndex{}); + QCOMPARE(rowsAboutToBeInsertedSpy.at(0).at(1), 0); + QCOMPARE(rowsAboutToBeInsertedSpy.at(0).at(2), 1); + + QCOMPARE(rowsInsertedSpy.count(), 1); + QCOMPARE(rowsInsertedSpy.at(0).at(0), QModelIndex{}); + QCOMPARE(rowsInsertedSpy.at(0).at(1), 0); + QCOMPARE(rowsInsertedSpy.at(0).at(2), 1); + + QCOMPARE(model.roleNames().size(), 3); + QCOMPARE(model.rowCount(), 2); + + // check if connections are established correctly + QSignalSpy dataChangedSpy(&model, &ConcatModel::dataChanged); + sourceModel1.setProperty(1, "color", "white"); + + QCOMPARE(dataChangedSpy.count(), 1); + QCOMPARE(dataChangedSpy.at(0).at(0), model.index(1, 0)); + QCOMPARE(dataChangedSpy.at(0).at(1), model.index(1, 0)); + } + { + QSignalSpy rowsInsertedSpy(&model, &ConcatModel::rowsInserted); + + { + QObject context; + connect(&model, &ConcatModel::rowsAboutToBeInserted, &context, + [&model] { QCOMPARE(model.rowCount(), 2); }); + + source2.setModel(sourceModel2.model()); + } + + QCOMPARE(rowsInsertedSpy.count(), 1); + QCOMPARE(rowsInsertedSpy.at(0).at(0), QModelIndex{}); + QCOMPARE(rowsInsertedSpy.at(0).at(1), 2); + QCOMPARE(rowsInsertedSpy.at(0).at(2), 3); + + QCOMPARE(model.roleNames().size(), 3); + QCOMPARE(model.rowCount(), 4); + + // check if connections are established correctly + QSignalSpy dataChangedSpy(&model, &ConcatModel::dataChanged); + sourceModel2.setProperty(1, "color", "black"); + + QCOMPARE(dataChangedSpy.count(), 1); + QCOMPARE(dataChangedSpy.at(0).at(0), model.index(3, 0)); + QCOMPARE(dataChangedSpy.at(0).at(1), model.index(3, 0)); + } + } + + void deferredEmptyModelSettingTest() + { + QQmlEngine engine; + ConcatModel model; + + ListModelWrapper sourceModel1(engine); + ListModelWrapper sourceModel2(engine); + + QQmlListProperty sources = model.sources(); + + SourceModel source1, source2; + + sources.append(&sources, &source1); + sources.append(&sources, &source2); + + QCOMPARE(model.rowCount(), 0); + QCOMPARE(model.roleNames(), {}); + QCOMPARE(model.index(0, 0).isValid(), false); + + model.componentComplete(); + + source1.setModel(sourceModel1.model()); + source2.setModel(sourceModel2.model()); + + QCOMPARE(model.rowCount(), 0); + QCOMPARE(model.roleNames(), {}); + + { + QSignalSpy rowsAboutToBeInsertedSpy( + &model, &ConcatModel::rowsAboutToBeInserted); + QSignalSpy rowsInsertedSpy(&model, &ConcatModel::rowsInserted); + + // checking validity inside rowsAboutToBeInserted signal + { + QObject context; + connect(&model, &ConcatModel::rowsAboutToBeInserted, &context, + [&model] { QCOMPARE(model.rowCount(), 0); }); + + sourceModel2.append(QJsonArray { + QJsonObject {{ "key", 1}, { "color", "red" }}, + QJsonObject {{ "key", 2}, { "color", "blue" }} + }); + } + + QCOMPARE(rowsAboutToBeInsertedSpy.count(), 1); + QCOMPARE(rowsAboutToBeInsertedSpy.at(0).at(0), QModelIndex{}); + QCOMPARE(rowsAboutToBeInsertedSpy.at(0).at(1), 0); + QCOMPARE(rowsAboutToBeInsertedSpy.at(0).at(2), 1); + + QCOMPARE(rowsInsertedSpy.count(), 1); + QCOMPARE(rowsInsertedSpy.at(0).at(0), QModelIndex{}); + QCOMPARE(rowsInsertedSpy.at(0).at(1), 0); + QCOMPARE(rowsInsertedSpy.at(0).at(2), 1); + + QCOMPARE(model.roleNames().size(), 3); + QCOMPARE(model.rowCount(), 2); + + // check if connections are established correctly + QSignalSpy dataChangedSpy(&model, &ConcatModel::dataChanged); + sourceModel2.setProperty(1, "color", "white"); + + QCOMPARE(dataChangedSpy.count(), 1); + QCOMPARE(dataChangedSpy.at(0).at(0), model.index(1, 0)); + QCOMPARE(dataChangedSpy.at(0).at(1), model.index(1, 0)); + } + { + QSignalSpy rowsInsertedSpy(&model, &ConcatModel::rowsInserted); + + { + QObject context; + connect(&model, &ConcatModel::rowsAboutToBeInserted, &context, + [&model] { QCOMPARE(model.rowCount(), 2); }); + + sourceModel1.append(QJsonArray { + QJsonObject {{ "key", 3}, { "color", "green" }, { "value", 42}}, + QJsonObject {{ "key", 4}, { "color", "white" }, { "value", 43}} + }); + } + + QCOMPARE(rowsInsertedSpy.count(), 1); + QCOMPARE(rowsInsertedSpy.at(0).at(0), QModelIndex{}); + QCOMPARE(rowsInsertedSpy.at(0).at(1), 0); + QCOMPARE(rowsInsertedSpy.at(0).at(2), 1); + + QCOMPARE(model.roleNames().size(), 3); + QCOMPARE(model.rowCount(), 4); + + // check if connections are established correctly + QSignalSpy dataChangedSpy(&model, &ConcatModel::dataChanged); + sourceModel2.setProperty(1, "color", "black"); + + QCOMPARE(dataChangedSpy.count(), 1); + QCOMPARE(dataChangedSpy.at(0).at(0), model.index(3, 0)); + QCOMPARE(dataChangedSpy.at(0).at(1), model.index(3, 0)); + } + } + + void settingModelsWithDifferentRolesTest() + { + QQmlEngine engine; + ConcatModel model; + + ListModelWrapper sourceModel1(engine, QJsonArray { + QJsonObject {{ "color", "red" }, { "key", 1}}, + QJsonObject {{ "color", "blue" }, { "key", 2}} + }); + ListModelWrapper sourceModel2(engine, QJsonArray { + QJsonObject {{ "key", 3}}, + QJsonObject {{ "key", 4}} + }); + ListModelWrapper sourceModel3(engine, QJsonArray { + QJsonObject {{ "key", 1}, { "color", "red" }, { "value", 42}}, + QJsonObject {{ "key", 2}, { "color", "blue" }, { "value", 42}} + }); + + QQmlListProperty sources = model.sources(); + + SourceModel source1, source2, source3; + + source1.setModel(sourceModel1.model()); + + sources.append(&sources, &source1); + sources.append(&sources, &source2); + sources.append(&sources, &source3); + + QCOMPARE(model.rowCount(), 0); + QCOMPARE(model.roleNames(), {}); + QCOMPARE(model.index(0, 0).isValid(), false); + + model.componentComplete(); + + QCOMPARE(model.rowCount(), 2); + QCOMPARE(model.roleNames().count(), 3); + + { + QSignalSpy rowsAboutToBeInsertedSpy( + &model, &ConcatModel::rowsAboutToBeInserted); + QSignalSpy rowsInsertedSpy(&model, &ConcatModel::rowsInserted); + + // checking validity inside rowsAboutToBeInserted signal + { + QObject context; + connect(&model, &ConcatModel::rowsAboutToBeInserted, &context, + [&model] { QCOMPARE(model.rowCount(), 2); }); + + source2.setModel(sourceModel2.model()); + } + + QCOMPARE(rowsAboutToBeInsertedSpy.count(), 1); + QCOMPARE(rowsAboutToBeInsertedSpy.at(0).at(0), QModelIndex{}); + QCOMPARE(rowsAboutToBeInsertedSpy.at(0).at(1), 2); + QCOMPARE(rowsAboutToBeInsertedSpy.at(0).at(2), 3); + + QCOMPARE(rowsInsertedSpy.count(), 1); + QCOMPARE(rowsInsertedSpy.at(0).at(0), QModelIndex{}); + QCOMPARE(rowsInsertedSpy.at(0).at(1), 2); + QCOMPARE(rowsInsertedSpy.at(0).at(2), 3); + + QCOMPARE(model.roleNames().size(), 3); + QCOMPARE(model.rowCount(), 4); + + // check if connections are established correctly + QSignalSpy dataChangedSpy(&model, &ConcatModel::dataChanged); + sourceModel1.setProperty(1, "color", "white"); + + QCOMPARE(dataChangedSpy.count(), 1); + QCOMPARE(dataChangedSpy.at(0).at(0), model.index(1, 0)); + QCOMPARE(dataChangedSpy.at(0).at(1), model.index(1, 0)); + } + + { + QSignalSpy rowsAboutToBeInsertedSpy( + &model, &ConcatModel::rowsAboutToBeInserted); + QSignalSpy rowsInsertedSpy(&model, &ConcatModel::rowsInserted); + + // checking validity inside rowsAboutToBeInserted signal + { + QObject context; + connect(&model, &ConcatModel::rowsAboutToBeInserted, &context, + [&model] { QCOMPARE(model.rowCount(), 4); }); + + source3.setModel(sourceModel3.model()); + } + + QCOMPARE(rowsAboutToBeInsertedSpy.count(), 1); + QCOMPARE(rowsAboutToBeInsertedSpy.at(0).at(0), QModelIndex{}); + QCOMPARE(rowsAboutToBeInsertedSpy.at(0).at(1), 4); + QCOMPARE(rowsAboutToBeInsertedSpy.at(0).at(2), 5); + + QCOMPARE(rowsInsertedSpy.count(), 1); + QCOMPARE(rowsInsertedSpy.at(0).at(0), QModelIndex{}); + QCOMPARE(rowsInsertedSpy.at(0).at(1), 4); + QCOMPARE(rowsInsertedSpy.at(0).at(2), 5); + + QCOMPARE(model.roleNames().size(), 3); + QCOMPARE(model.rowCount(), 6); + + // check if connections are established correctly + QSignalSpy dataChangedSpy(&model, &ConcatModel::dataChanged); + sourceModel3.setProperty(0, "color", "white"); + + QCOMPARE(dataChangedSpy.count(), 1); + QCOMPARE(dataChangedSpy.at(0).at(0), model.index(4, 0)); + QCOMPARE(dataChangedSpy.at(0).at(1), model.index(4, 0)); + } + } + + void unsettingModelsTest() + { + QQmlEngine engine; + ConcatModel model; + + ListModelWrapper sourceModel1(engine, QJsonArray { + QJsonObject {{ "key", 1}, { "color", "red" }}, + QJsonObject {{ "key", 2}, { "color", "blue" }} + }); + ListModelWrapper sourceModel2(engine, QJsonArray { + QJsonObject {{ "key", 3}}, + QJsonObject {{ "key", 4}} + }); + ListModelWrapper sourceModel3(engine, QJsonArray { + QJsonObject {{ "key", 5}, { "color", "red" }, { "value", 42}}, + QJsonObject {{ "key", 6}, { "color", "green" }, { "value", 43}} + }); + + QQmlListProperty sources = model.sources(); + + SourceModel source1, source2, source3; + + source1.setModel(sourceModel1.model()); + source2.setModel(sourceModel2.model()); + source3.setModel(sourceModel3.model()); + + sources.append(&sources, &source1); + sources.append(&sources, &source2); + sources.append(&sources, &source3); + + QCOMPARE(model.rowCount(), 0); + QCOMPARE(model.roleNames(), {}); + QCOMPARE(model.index(0, 0).isValid(), false); + + model.componentComplete(); + + QCOMPARE(model.rowCount(), 6); + QCOMPARE(model.roleNames().count(), 4); + + { + QSignalSpy rowsAboutToBeRemovedSpy(&model, &ConcatModel::rowsAboutToBeRemoved); + QSignalSpy rowsRemovedSpy(&model, &ConcatModel::rowsRemoved); + + // checking validity inside rowsAboutToBeRemoved signal + { + QObject context; + connect(&model, &ConcatModel::rowsAboutToBeRemoved, &context, + [&model] { QCOMPARE(model.rowCount(), 6); }); + + source1.setModel(nullptr); + } + + QCOMPARE(model.rowCount(), 4); + + QCOMPARE(rowsAboutToBeRemovedSpy.count(), 1); + QCOMPARE(rowsRemovedSpy.count(), 1); + + QCOMPARE(rowsAboutToBeRemovedSpy.at(0).at(0), QModelIndex{}); + QCOMPARE(rowsAboutToBeRemovedSpy.at(0).at(1), 0); + QCOMPARE(rowsAboutToBeRemovedSpy.at(0).at(2), 1); + + QCOMPARE(rowsRemovedSpy.at(0).at(0), QModelIndex{}); + QCOMPARE(rowsRemovedSpy.at(0).at(1), 0); + QCOMPARE(rowsRemovedSpy.at(0).at(2), 1); + } + { + QSignalSpy rowsAboutToBeRemovedSpy(&model, &ConcatModel::rowsAboutToBeRemoved); + QSignalSpy rowsRemovedSpy(&model, &ConcatModel::rowsRemoved); + + // checking validity inside rowsAboutToBeRemoved signal + { + QObject context; + connect(&model, &ConcatModel::rowsAboutToBeRemoved, &context, + [&model] { QCOMPARE(model.rowCount(), 4); }); + + source3.setModel(nullptr); + } + + QCOMPARE(rowsAboutToBeRemovedSpy.count(), 1); + QCOMPARE(rowsRemovedSpy.count(), 1); + + QCOMPARE(rowsAboutToBeRemovedSpy.at(0).at(0), QModelIndex{}); + QCOMPARE(rowsAboutToBeRemovedSpy.at(0).at(1), 2); + QCOMPARE(rowsAboutToBeRemovedSpy.at(0).at(2), 3); + + QCOMPARE(rowsRemovedSpy.at(0).at(0), QModelIndex{}); + QCOMPARE(rowsRemovedSpy.at(0).at(1), 2); + QCOMPARE(rowsRemovedSpy.at(0).at(2), 3); + } + { + QSignalSpy rowsAboutToBeRemovedSpy(&model, &ConcatModel::rowsAboutToBeRemoved); + QSignalSpy rowsRemovedSpy(&model, &ConcatModel::rowsRemoved); + + // checking validity inside rowsAboutToBeRemoved signal + { + QObject context; + connect(&model, &ConcatModel::rowsAboutToBeRemoved, &context, + [&model] { QCOMPARE(model.rowCount(), 2); }); + + source2.setModel(nullptr); + } + + QCOMPARE(rowsAboutToBeRemovedSpy.count(), 1); + QCOMPARE(rowsRemovedSpy.count(), 1); + + QCOMPARE(rowsAboutToBeRemovedSpy.at(0).at(0), QModelIndex{}); + QCOMPARE(rowsAboutToBeRemovedSpy.at(0).at(1), 0); + QCOMPARE(rowsAboutToBeRemovedSpy.at(0).at(2), 1); + + QCOMPARE(rowsRemovedSpy.at(0).at(0), QModelIndex{}); + QCOMPARE(rowsRemovedSpy.at(0).at(1), 0); + QCOMPARE(rowsRemovedSpy.at(0).at(2), 1); + } + { + QSignalSpy rowsAboutToBeRemovedSpy(&model, &ConcatModel::rowsAboutToBeRemoved); + QSignalSpy rowsRemovedSpy(&model, &ConcatModel::rowsRemoved); + + source2.setModel(nullptr); + + QCOMPARE(rowsAboutToBeRemovedSpy.count(), 0); + QCOMPARE(rowsRemovedSpy.count(), 0); + } + + // check if signals are disconnected properly + { + QSignalSpy dataChangedSpy(&model, &ConcatModel::dataChanged); + sourceModel1.setProperty(0, "key", 11); + sourceModel2.setProperty(0, "key", 12); + sourceModel3.setProperty(0, "key", 13); + + QCOMPARE(dataChangedSpy.count(), 0); + } + } + + void replacingModelsTest() + { + QQmlEngine engine; + ConcatModel model; + + ListModelWrapper sourceModel1(engine, QJsonArray { + QJsonObject {{ "key", 1}, { "color", "red" }}, + QJsonObject {{ "key", 2}, { "color", "blue" }} + }); + ListModelWrapper sourceModel2(engine, QJsonArray { + QJsonObject {{ "key", 3}}, + QJsonObject {{ "key", 4}} + }); + ListModelWrapper sourceModel3(engine, QJsonArray { + QJsonObject {{ "key", 5}, { "color", "red" }, { "value", 42}}, + QJsonObject {{ "key", 6}, { "color", "green" }, { "value", 43}} + }); + ListModelWrapper sourceModel4(engine, QJsonArray { + QJsonObject {{ "color", "orange" }, { "value", 44}, { "key", 7}}, + QJsonObject {{ "color", "green" }, { "value", 45}, { "key", 8}}, + QJsonObject {{ "color", "brown" }, { "value", 46}, { "key", 9}} + }); + + QQmlListProperty sources = model.sources(); + + SourceModel source1, source2, source3; + + source1.setModel(sourceModel1.model()); + source2.setModel(sourceModel2.model()); + source3.setModel(sourceModel3.model()); + + sources.append(&sources, &source1); + sources.append(&sources, &source2); + sources.append(&sources, &source3); + + QCOMPARE(model.rowCount(), 0); + QCOMPARE(model.roleNames(), {}); + QCOMPARE(model.index(0, 0).isValid(), false); + + model.componentComplete(); + + QCOMPARE(model.rowCount(), 6); + QCOMPARE(model.roleNames().count(), 4); + + QSignalSpy rowsAboutToBeRemovedSpy(&model, &ConcatModel::rowsAboutToBeRemoved); + QSignalSpy rowsRemovedSpy(&model, &ConcatModel::rowsRemoved); + + QSignalSpy rowsAboutToBeInsertedSpy( + &model, &ConcatModel::rowsAboutToBeInserted); + QSignalSpy rowsInsertedSpy(&model, &ConcatModel::rowsInserted); + + // checking validity inside rowsAboutToBeRemoved signal + { + QObject context; + connect(&model, &ConcatModel::rowsAboutToBeRemoved, &context, + [&model] { QCOMPARE(model.rowCount(), 6); }); + + connect(&model, &ConcatModel::rowsRemoved, &context, + [&model] { QCOMPARE(model.rowCount(), 4); }); + + connect(&model, &ConcatModel::rowsAboutToBeInserted, &context, + [&model] { QCOMPARE(model.rowCount(), 4); }); + + source2.setModel(sourceModel4.model()); + } + + QCOMPARE(model.rowCount(), 7); + + QCOMPARE(rowsAboutToBeRemovedSpy.count(), 1); + QCOMPARE(rowsRemovedSpy.count(), 1); + + QCOMPARE(rowsAboutToBeInsertedSpy.count(), 1); + QCOMPARE(rowsInsertedSpy.count(), 1); + + QCOMPARE(rowsAboutToBeRemovedSpy.at(0).at(0), QModelIndex{}); + QCOMPARE(rowsAboutToBeRemovedSpy.at(0).at(1), 2); + QCOMPARE(rowsAboutToBeRemovedSpy.at(0).at(2), 3); + + QCOMPARE(rowsRemovedSpy.at(0).at(0), QModelIndex{}); + QCOMPARE(rowsRemovedSpy.at(0).at(1), 2); + QCOMPARE(rowsRemovedSpy.at(0).at(2), 3); + + QCOMPARE(rowsAboutToBeInsertedSpy.count(), 1); + QCOMPARE(rowsAboutToBeInsertedSpy.at(0).at(0), QModelIndex{}); + QCOMPARE(rowsAboutToBeInsertedSpy.at(0).at(1), 2); + QCOMPARE(rowsAboutToBeInsertedSpy.at(0).at(2), 4); + + QCOMPARE(rowsInsertedSpy.count(), 1); + QCOMPARE(rowsInsertedSpy.at(0).at(0), QModelIndex{}); + QCOMPARE(rowsInsertedSpy.at(0).at(1), 2); + QCOMPARE(rowsInsertedSpy.at(0).at(2), 4); + + // check if previous model is disconnected + QSignalSpy dataChangedSpy(&model, &ConcatModel::dataChanged); + sourceModel2.setProperty(0, "key", 234); + + QCOMPARE(dataChangedSpy.count(), 0); + + // content validation + auto roles = model.roleNames(); + + QCOMPARE(roles.count(), 4); + + QCOMPARE(model.data(model.index(0, 0), roleForName(roles, "key")), 1); + QCOMPARE(model.data(model.index(1, 0), roleForName(roles, "key")), 2); + QCOMPARE(model.data(model.index(2, 0), roleForName(roles, "key")), 7); + QCOMPARE(model.data(model.index(3, 0), roleForName(roles, "key")), 8); + QCOMPARE(model.data(model.index(4, 0), roleForName(roles, "key")), 9); + QCOMPARE(model.data(model.index(5, 0), roleForName(roles, "key")), 5); + QCOMPARE(model.data(model.index(6, 0), roleForName(roles, "key")), 6); + + QCOMPARE(model.data(model.index(0, 0), roleForName(roles, "color")), "red"); + QCOMPARE(model.data(model.index(1, 0), roleForName(roles, "color")), "blue"); + QCOMPARE(model.data(model.index(2, 0), roleForName(roles, "color")), "orange"); + QCOMPARE(model.data(model.index(3, 0), roleForName(roles, "color")), "green"); + QCOMPARE(model.data(model.index(4, 0), roleForName(roles, "color")), "brown"); + QCOMPARE(model.data(model.index(5, 0), roleForName(roles, "color")), "red"); + QCOMPARE(model.data(model.index(6, 0), roleForName(roles, "color")), "green"); + + QCOMPARE(model.data(model.index(0, 0), roleForName(roles, "value")), {}); + QCOMPARE(model.data(model.index(1, 0), roleForName(roles, "value")), {}); + QCOMPARE(model.data(model.index(2, 0), roleForName(roles, "value")), 44); + QCOMPARE(model.data(model.index(3, 0), roleForName(roles, "value")), 45); + QCOMPARE(model.data(model.index(4, 0), roleForName(roles, "value")), 46); + QCOMPARE(model.data(model.index(5, 0), roleForName(roles, "value")), 42); + QCOMPARE(model.data(model.index(6, 0), roleForName(roles, "value")), 43); + } + + void deletingModelsTest() + { + QQmlEngine engine; + ConcatModel model; + + ListModelWrapper sourceModel1(engine, QJsonArray { + QJsonObject {{ "key", 1}, { "color", "red" }}, + QJsonObject {{ "key", 2}, { "color", "blue" }} + }); + auto sourceModel2 = std::make_unique(engine, QJsonArray { + QJsonObject {{ "key", 3}}, + QJsonObject {{ "key", 4}} + }); + ListModelWrapper sourceModel3(engine, QJsonArray { + QJsonObject {{ "key", 5}, { "color", "red" }, { "value", 42}}, + QJsonObject {{ "key", 6}, { "color", "green" }, { "value", 43}} + }); + + QQmlListProperty sources = model.sources(); + + SourceModel source1, source2, source3; + + source1.setModel(sourceModel1.model()); + source2.setModel(sourceModel2->model()); + source3.setModel(sourceModel3.model()); + + sources.append(&sources, &source1); + sources.append(&sources, &source2); + sources.append(&sources, &source3); + + QCOMPARE(model.rowCount(), 0); + QCOMPARE(model.roleNames(), {}); + QCOMPARE(model.index(0, 0).isValid(), false); + + model.componentComplete(); + + auto roles = model.roleNames(); + + QCOMPARE(model.rowCount(), 6); + QCOMPARE(roles.count(), 4); + + QSignalSpy rowsAboutToBeRemovedSpy(&model, &ConcatModel::rowsAboutToBeRemoved); + QSignalSpy rowsRemovedSpy(&model, &ConcatModel::rowsRemoved); + + // checking validity inside rowsAboutToBeRemoved signal + { + QObject context; + connect(&model, &ConcatModel::rowsAboutToBeRemoved, &context, + [this, &model, &roles] { + QCOMPARE(model.rowCount(), 6); + + QCOMPARE(model.data(model.index(0, 0), roleForName(roles, "key")), 1); + QCOMPARE(model.data(model.index(1, 0), roleForName(roles, "key")), 2); + QCOMPARE(model.data(model.index(2, 0), roleForName(roles, "key")), {}); + QCOMPARE(model.data(model.index(3, 0), roleForName(roles, "key")), {}); + QCOMPARE(model.data(model.index(4, 0), roleForName(roles, "key")), 5); + QCOMPARE(model.data(model.index(5, 0), roleForName(roles, "key")), 6); + + QCOMPARE(model.data(model.index(0, 0), roleForName(roles, "whichModel")), ""); + QCOMPARE(model.data(model.index(1, 0), roleForName(roles, "whichModel")), ""); + QCOMPARE(model.data(model.index(2, 0), roleForName(roles, "whichModel")), ""); + QCOMPARE(model.data(model.index(3, 0), roleForName(roles, "whichModel")), ""); + QCOMPARE(model.data(model.index(4, 0), roleForName(roles, "whichModel")), ""); + QCOMPARE(model.data(model.index(5, 0), roleForName(roles, "whichModel")), ""); + }); + + sourceModel2.reset(); + } + + QCOMPARE(model.rowCount(), 4); + + QCOMPARE(rowsAboutToBeRemovedSpy.count(), 1); + QCOMPARE(rowsRemovedSpy.count(), 1); + + QCOMPARE(rowsAboutToBeRemovedSpy.at(0).at(0), QModelIndex{}); + QCOMPARE(rowsAboutToBeRemovedSpy.at(0).at(1), 2); + QCOMPARE(rowsAboutToBeRemovedSpy.at(0).at(2), 3); + + QCOMPARE(rowsRemovedSpy.at(0).at(0), QModelIndex{}); + QCOMPARE(rowsRemovedSpy.at(0).at(1), 2); + QCOMPARE(rowsRemovedSpy.at(0).at(2), 3); + } + + void removalTest() + { + QQmlEngine engine; + ConcatModel model; + + ListModelWrapper sourceModel1(engine, QJsonArray { + QJsonObject {{ "key", 1}, { "color", "red" }}, + QJsonObject {{ "key", 2}, { "color", "blue" }} + }); + ListModelWrapper sourceModel2(engine, QJsonArray { + QJsonObject {{ "key", 3}}, + QJsonObject {{ "key", 4}}, + QJsonObject {{ "key", 5}} + }); + ListModelWrapper sourceModel3(engine, QJsonArray { + QJsonObject {{ "key", 6}, { "color", "red" }, { "value", 42}}, + QJsonObject {{ "key", 7}, { "color", "green" }, { "value", 43}} + }); + + QQmlListProperty sources = model.sources(); + + SourceModel source1, source2, source3; + + source1.setModel(sourceModel1.model()); + source2.setModel(sourceModel2.model()); + source3.setModel(sourceModel3.model()); + + sources.append(&sources, &source1); + sources.append(&sources, &source2); + sources.append(&sources, &source3); + + QCOMPARE(model.rowCount(), 0); + QCOMPARE(model.roleNames(), {}); + QCOMPARE(model.index(0, 0).isValid(), false); + + model.componentComplete(); + + auto roles = model.roleNames(); + + QCOMPARE(model.rowCount(), 7); + QCOMPARE(roles.count(), 4); + + QSignalSpy rowsAboutToBeRemovedSpy(&model, &ConcatModel::rowsAboutToBeRemoved); + QSignalSpy rowsRemovedSpy(&model, &ConcatModel::rowsRemoved); + + // checking validity inside rowsAboutToBeRemoved signal + { + QObject context; + connect(&model, &ConcatModel::rowsAboutToBeRemoved, &context, + [this, &model, &roles] { + QCOMPARE(model.rowCount(), 7); + QCOMPARE(model.data(model.index(3, 0), roleForName(roles, "key")), 4); + }); + + sourceModel2.remove(1, 2); + } + + QCOMPARE(model.rowCount(), 5); + + QCOMPARE(rowsAboutToBeRemovedSpy.count(), 1); + QCOMPARE(rowsRemovedSpy.count(), 1); + + QCOMPARE(rowsAboutToBeRemovedSpy.at(0).at(0), QModelIndex{}); + QCOMPARE(rowsAboutToBeRemovedSpy.at(0).at(1), 3); + QCOMPARE(rowsAboutToBeRemovedSpy.at(0).at(2), 4); + + QCOMPARE(rowsRemovedSpy.at(0).at(0), QModelIndex{}); + QCOMPARE(rowsRemovedSpy.at(0).at(1), 3); + QCOMPARE(rowsRemovedSpy.at(0).at(2), 4); + + QCOMPARE(model.data(model.index(0, 0), roleForName(roles, "key")), 1); + QCOMPARE(model.data(model.index(1, 0), roleForName(roles, "key")), 2); + QCOMPARE(model.data(model.index(2, 0), roleForName(roles, "key")), 3); + QCOMPARE(model.data(model.index(3, 0), roleForName(roles, "key")), 6); + QCOMPARE(model.data(model.index(4, 0), roleForName(roles, "key")), 7); + } + + void moveTest() + { + QQmlEngine engine; + ConcatModel model; + + ListModelWrapper sourceModel1(engine, QJsonArray { + QJsonObject {{ "key", 1}, { "color", "red" }}, + QJsonObject {{ "key", 2}, { "color", "blue" }} + }); + ListModelWrapper sourceModel2(engine, QJsonArray { + QJsonObject {{ "key", 3}}, + QJsonObject {{ "key", 4}}, + QJsonObject {{ "key", 5}} + }); + ListModelWrapper sourceModel3(engine, QJsonArray { + QJsonObject {{ "key", 6}, { "color", "red" }, { "value", 42}}, + QJsonObject {{ "key", 7}, { "color", "green" }, { "value", 43}} + }); + + QQmlListProperty sources = model.sources(); + + SourceModel source1, source2, source3; + + source1.setModel(sourceModel1.model()); + source2.setModel(sourceModel2.model()); + source3.setModel(sourceModel3.model()); + + sources.append(&sources, &source1); + sources.append(&sources, &source2); + sources.append(&sources, &source3); + + QCOMPARE(model.rowCount(), 0); + QCOMPARE(model.roleNames(), {}); + QCOMPARE(model.index(0, 0).isValid(), false); + + model.componentComplete(); + + auto roles = model.roleNames(); + + QCOMPARE(model.rowCount(), 7); + QCOMPARE(roles.count(), 4); + + QSignalSpy rowsAboutToBeMovedSpy(&model, &ConcatModel::rowsAboutToBeMoved); + QSignalSpy rowsMovedSpy(&model, &ConcatModel::rowsMoved); + + // checking validity inside rowsAboutToBeRemoved signal + { + QObject context; + connect(&model, &ConcatModel::rowsAboutToBeMoved, &context, + [this, &model, &roles] { + QCOMPARE(model.rowCount(), 7); + QCOMPARE(model.data(model.index(3, 0), roleForName(roles, "key")), 4); + }); + + sourceModel2.move(1, 0, 2); + } + + QCOMPARE(model.rowCount(), 7); + + QCOMPARE(rowsAboutToBeMovedSpy.count(), 1); + QCOMPARE(rowsMovedSpy.count(), 1); + + QCOMPARE(rowsAboutToBeMovedSpy.at(0).at(0), QModelIndex{}); + QCOMPARE(rowsAboutToBeMovedSpy.at(0).at(1), 3); + QCOMPARE(rowsAboutToBeMovedSpy.at(0).at(2), 4); + QCOMPARE(rowsAboutToBeMovedSpy.at(0).at(3), QModelIndex{}); + QCOMPARE(rowsAboutToBeMovedSpy.at(0).at(4), 2); + + QCOMPARE(rowsMovedSpy.at(0).at(0), QModelIndex{}); + QCOMPARE(rowsMovedSpy.at(0).at(1), 3); + QCOMPARE(rowsMovedSpy.at(0).at(2), 4); + QCOMPARE(rowsMovedSpy.at(0).at(3), QModelIndex{}); + QCOMPARE(rowsMovedSpy.at(0).at(4), 2); + } + + void layoutChangedTest() + { + QQmlEngine engine; + ConcatModel model; + + ListModelWrapper sourceModel1(engine, QJsonArray { + QJsonObject {{ "key", 1}, { "color", "red" }}, + QJsonObject {{ "key", 2}, { "color", "blue" }} + }); + ListModelWrapper sourceModel2(engine, QJsonArray { + QJsonObject {{ "key", 3}}, + QJsonObject {{ "key", 4}}, + QJsonObject {{ "key", 5}} + }); + + QQmlListProperty sources = model.sources(); + + SourceModel source1, source2, source3; + + source1.setModel(sourceModel1.model()); + source2.setModel(sourceModel2.model()); + + sources.append(&sources, &source1); + sources.append(&sources, &source2); + + QCOMPARE(model.rowCount(), 0); + QCOMPARE(model.roleNames(), {}); + QCOMPARE(model.index(0, 0).isValid(), false); + + model.componentComplete(); + + auto roles = model.roleNames(); + + QCOMPARE(model.rowCount(), 5); + QCOMPARE(roles.count(), 3); + + // register types to avoid warnings regarding signal params + qRegisterMetaType>(); + qRegisterMetaType(); + + QSignalSpy layoutAboutToBeChangedSpy(&model, &ConcatModel::layoutAboutToBeChanged); + QSignalSpy layoutChangedSpy(&model, &ConcatModel::layoutChanged); + + emit sourceModel1.model()->layoutAboutToBeChanged(); + emit sourceModel1.model()->layoutChanged(); + + QCOMPARE(layoutAboutToBeChangedSpy.count(), 1); + QCOMPARE(layoutChangedSpy.count(), 1); + } + + void layoutResetTest() + { + // TODO + } + + void sameModelUsedMultipleTimesTest() + { + QQmlEngine engine; + ConcatModel model; + + ListModelWrapper sourceModel(engine, QJsonArray { + QJsonObject {{ "key", 1}, { "color", "red" }}, + QJsonObject {{ "key", 2}, { "color", "blue" }} + }); + + QQmlListProperty sources = model.sources(); + + SourceModel source1, source2, source3; + + source1.setModel(sourceModel.model()); + source2.setModel(sourceModel.model()); + source3.setModel(sourceModel.model()); + + sources.append(&sources, &source1); + sources.append(&sources, &source2); + sources.append(&sources, &source3); + + QCOMPARE(model.rowCount(), 0); + QCOMPARE(model.roleNames(), {}); + QCOMPARE(model.index(0, 0).isValid(), false); + + model.componentComplete(); + + auto roles = model.roleNames(); + + QCOMPARE(model.rowCount(), 6); + QCOMPARE(roles.count(), 3); + + QSignalSpy rowsAboutToBeInsertedSpy( + &model, &ConcatModel::rowsAboutToBeInserted); + QSignalSpy rowsInsertedSpy(&model, &ConcatModel::rowsInserted); + + sourceModel.insert(1, QJsonObject {{ "key", 15 }, { "color", "black" }}); + + QCOMPARE(model.rowCount(), 9); + + QCOMPARE(rowsAboutToBeInsertedSpy.count(), 3); + QCOMPARE(rowsAboutToBeInsertedSpy.at(0).at(0), QModelIndex{}); + QCOMPARE(rowsAboutToBeInsertedSpy.at(0).at(1), 1); + QCOMPARE(rowsAboutToBeInsertedSpy.at(0).at(2), 1); + QCOMPARE(rowsAboutToBeInsertedSpy.at(1).at(0), QModelIndex{}); + QCOMPARE(rowsAboutToBeInsertedSpy.at(1).at(1), 3); + QCOMPARE(rowsAboutToBeInsertedSpy.at(1).at(2), 3); + QCOMPARE(rowsAboutToBeInsertedSpy.at(2).at(0), QModelIndex{}); + QCOMPARE(rowsAboutToBeInsertedSpy.at(2).at(1), 5); + QCOMPARE(rowsAboutToBeInsertedSpy.at(2).at(2), 5); + + QCOMPARE(rowsInsertedSpy.count(), 3); + QCOMPARE(rowsInsertedSpy.at(0).at(0), QModelIndex{}); + QCOMPARE(rowsInsertedSpy.at(0).at(1), 5); + QCOMPARE(rowsInsertedSpy.at(0).at(2), 5); + QCOMPARE(rowsInsertedSpy.at(1).at(0), QModelIndex{}); + QCOMPARE(rowsInsertedSpy.at(1).at(1), 3); + QCOMPARE(rowsInsertedSpy.at(1).at(2), 3); + QCOMPARE(rowsInsertedSpy.at(2).at(0), QModelIndex{}); + QCOMPARE(rowsInsertedSpy.at(2).at(1), 1); + QCOMPARE(rowsInsertedSpy.at(2).at(2), 1); + + QSignalSpy dataChangedSpy(&model, &ConcatModel::dataChanged); + sourceModel.setProperty(0, "key", 0); + + QCOMPARE(model.rowCount(), 9); + + QCOMPARE(dataChangedSpy.count(), 3); + QCOMPARE(dataChangedSpy.at(0).at(0), model.index(0, 0)); + QCOMPARE(dataChangedSpy.at(0).at(1), model.index(0, 0)); + QCOMPARE(dataChangedSpy.at(0).at(2).value>(), + { roleForName(roles, "key") }); + + QCOMPARE(dataChangedSpy.at(1).at(0), model.index(3, 0)); + QCOMPARE(dataChangedSpy.at(1).at(1), model.index(3, 0)); + QCOMPARE(dataChangedSpy.at(1).at(2).value>(), + { roleForName(roles, "key") }); + + QCOMPARE(dataChangedSpy.at(2).at(0), model.index(6, 0)); + QCOMPARE(dataChangedSpy.at(2).at(1), model.index(6, 0)); + QCOMPARE(dataChangedSpy.at(2).at(2).value>(), + { roleForName(roles, "key") }); + + QCOMPARE(model.data(model.index(0, 0), roleForName(roles, "key")), 0); + QCOMPARE(model.data(model.index(1, 0), roleForName(roles, "key")), 15); + QCOMPARE(model.data(model.index(2, 0), roleForName(roles, "key")), 2); + QCOMPARE(model.data(model.index(3, 0), roleForName(roles, "key")), 0); + QCOMPARE(model.data(model.index(4, 0), roleForName(roles, "key")), 15); + QCOMPARE(model.data(model.index(5, 0), roleForName(roles, "key")), 2); + QCOMPARE(model.data(model.index(6, 0), roleForName(roles, "key")), 0); + QCOMPARE(model.data(model.index(7, 0), roleForName(roles, "key")), 15); + QCOMPARE(model.data(model.index(8, 0), roleForName(roles, "key")), 2); + } + + void expectedRolesTest() + { + QQmlEngine engine; + ConcatModel model; + + ListModelWrapper sourceModel1(engine, QJsonArray { + QJsonObject {{ "key", 1}}, + QJsonObject {{ "key", 2}}, + QJsonObject {{ "key", 3}} + }); + ListModelWrapper sourceModel2(engine, QJsonArray { + QJsonObject {{ "key", 4}, { "color", "red" }, { "name", "name 1"}}, + QJsonObject {{ "key", 5}, { "color", "blue" }, { "name", "name 2"}} + }); + ListModelWrapper sourceModel3(engine, QJsonArray { + QJsonObject {{ "key", 6}, { "color", "red" }, { "value", 42}}, + QJsonObject {{ "key", 7}, { "color", "green" }, { "value", 43}} + }); + + QQmlListProperty sources = model.sources(); + + SourceModel source1, source2, source3; + source1.setModel(sourceModel1.model()); + + sources.append(&sources, &source1); + sources.append(&sources, &source2); + sources.append(&sources, &source3); + + model.setExpectedRoles({"key", "color", "value"}); + + QCOMPARE(model.rowCount(), 0); + QCOMPARE(model.roleNames(), {}); + QCOMPARE(model.index(0, 0).isValid(), false); + + model.componentComplete(); + + auto roles = model.roleNames(); + + QCOMPARE(model.rowCount(), 3); + QCOMPARE(roles.size(), 4); + + std::set roleNamesSet(roles.cbegin(), roles.cend()); + std::set expectedRoleNamesSet({"key", "color", "value", "whichModel"}); + QCOMPARE(roleNamesSet, expectedRoleNamesSet); + + source2.setModel(sourceModel2.model()); + source3.setModel(sourceModel3.model()); + + QCOMPARE(model.rowCount(), 7); + } + + void markerRoleTest() + { + QQmlEngine engine; + ConcatModel model; + + ListModelWrapper sourceModel1(engine, QJsonArray { + QJsonObject {{ "key", 1}, { "balance", 11 }, { "name", "n1" }}, + QJsonObject {{ "key", 2},{ "balance", 12 }, { "name", "n2" }}, + QJsonObject {{ "key", 3},{ "balance", 13}, { "name", "n3" }}, + }); + + ListModelWrapper sourceModel2(engine, QJsonArray {}); + + ListModelWrapper sourceModel3(engine, QJsonArray { + QJsonObject {{ "balance", 14 }, { "name", "n4" }, { "color", "red"}}, + QJsonObject {{ "balance", 15 }, { "name", "n5" }, { "color", "green"}}, + QJsonObject {{ "balance", 16 }, { "name", "n6" }, { "color", "blue"}}, + QJsonObject {{ "balance", 17 }, { "name", "n7" }, { "color", "pink"}}, + }); + + ListModelWrapper sourceModel4(engine, QJsonArray {}); + + QQmlListProperty sources = model.sources(); + + SourceModel source1, source2, source3, source4; + source1.setModel(sourceModel1.model()); + source2.setModel(sourceModel2.model()); + source3.setModel(sourceModel3.model()); + source4.setModel(sourceModel4.model()); + + source1.setMarkerRoleValue("model 1"); + source2.setMarkerRoleValue("model 2"); + source3.setMarkerRoleValue("model 3"); + source4.setMarkerRoleValue("model 4"); + + sources.append(&sources, &source1); + sources.append(&sources, &source2); + sources.append(&sources, &source3); + sources.append(&sources, &source4); + + QCOMPARE(model.rowCount(), 0); + QCOMPARE(model.roleNames(), {}); + QCOMPARE(model.index(0, 0).isValid(), false); + + model.setMarkerRoleName("marker"); + + model.componentComplete(); + + QCOMPARE(model.markerRoleName(), "marker"); + QCOMPARE(source1.markerRoleValue(), "model 1"); + QCOMPARE(source2.markerRoleValue(), "model 2"); + QCOMPARE(source3.markerRoleValue(), "model 3"); + QCOMPARE(source4.markerRoleValue(), "model 4"); + + QTest::ignoreMessage(QtWarningMsg, + "Property \"markerRoleName\" is intended to be " + "initialized once before roles initialization " + "and not modified later."); + model.setMarkerRoleName("marker2"); + + QCOMPARE(model.markerRoleName(), "marker"); + + auto roles = model.roleNames(); + + QCOMPARE(model.rowCount(), 7); + QCOMPARE(roles.size(), 5); + QVERIFY(roleForName(roles, "marker") != -1); + + QCOMPARE(model.data(model.index(0, 0), roleForName(roles, "marker")), "model 1"); + QCOMPARE(model.data(model.index(1, 0), roleForName(roles, "marker")), "model 1"); + QCOMPARE(model.data(model.index(2, 0), roleForName(roles, "marker")), "model 1"); + QCOMPARE(model.data(model.index(3, 0), roleForName(roles, "marker")), "model 3"); + QCOMPARE(model.data(model.index(4, 0), roleForName(roles, "marker")), "model 3"); + QCOMPARE(model.data(model.index(5, 0), roleForName(roles, "marker")), "model 3"); + QCOMPARE(model.data(model.index(6, 0), roleForName(roles, "marker")), "model 3"); + + { + QSignalSpy dataChangedSpy(&model, &ConcatModel::dataChanged); + source1.setMarkerRoleValue("model 1_"); + + QCOMPARE(dataChangedSpy.count(), 1); + QCOMPARE(dataChangedSpy.at(0).at(0), model.index(0, 0)); + QCOMPARE(dataChangedSpy.at(0).at(1), model.index(2, 0)); + QCOMPARE(dataChangedSpy.at(0).at(2).value>(), + { roleForName(roles, "marker") }); + } + { + QSignalSpy dataChangedSpy(&model, &ConcatModel::dataChanged); + source2.setMarkerRoleValue("model 2_"); + + QCOMPARE(dataChangedSpy.count(), 0); + } + { + QSignalSpy dataChangedSpy(&model, &ConcatModel::dataChanged); + source3.setMarkerRoleValue("model 3_"); + + QCOMPARE(dataChangedSpy.count(), 1); + QCOMPARE(dataChangedSpy.at(0).at(0), model.index(3, 0)); + QCOMPARE(dataChangedSpy.at(0).at(1), model.index(6, 0)); + QCOMPARE(dataChangedSpy.at(0).at(2).value>(), + { roleForName(roles, "marker") }); + } + { + QSignalSpy dataChangedSpy(&model, &ConcatModel::dataChanged); + source4.setMarkerRoleValue("model 4_"); + + QCOMPARE(dataChangedSpy.count(), 0); + } + } +}; + +QTEST_MAIN(TestConcatModel) +#include "tst_ConcatModel.moc"