#include #include #include #include #include "managetokenscontroller.h" #include "managetokensmodel.h" #include "tokendata.h" // Minimal source model mirroring the roles ManageTokensController::addItem reads. // Rows are role-name -> value maps; role integers are assigned once from a fixed // ordered role list so roleNames() is stable and non-empty from construction // (the controller has a separate code path for models whose roles appear late). class SourceModel : public QAbstractListModel { Q_OBJECT public: explicit SourceModel(QObject* parent = nullptr) : QAbstractListModel(parent) { const QList names{"key", "tokenKey", "crossChainId", "symbol", "name", "communityId", "communityName", "communityImage", "collectionUid", "collectionName", "enabledNetworkBalance", "enabledNetworkCurrencyBalance", "imageUrl", "image", "mediaUrl", "backgroundColor", "balances", "decimals", "marketDetails"}; int role = Qt::UserRole + 1; for (const auto& n : names) { m_roleForName.insert(n, role); m_nameForRole.insert(role, n); role++; } } int rowCount(const QModelIndex& parent = {}) const override { return parent.isValid() ? 0 : m_rows.size(); } QHash roleNames() const override { return m_nameForRole; } QVariant data(const QModelIndex& index, int role) const override { if (!checkIndex(index, CheckIndexOption::IndexIsValid | CheckIndexOption::ParentIsInvalid)) return {}; return m_rows.at(index.row()).value(m_nameForRole.value(role)); } // --- test-side mutation API --- void appendRow(const QHash& row) { const int r = m_rows.size(); beginInsertRows({}, r, r); m_rows.append(row); endInsertRows(); } void removeRow(int row) { beginRemoveRows({}, row, row); m_rows.removeAt(row); endRemoveRows(); } void resetWith(const QList>& rows) { beginResetModel(); m_rows = rows; endResetModel(); } // Change a single cell and notify with exactly the given role (data-only churn). void updateCell(int row, const QByteArray& roleName, const QVariant& value) { m_rows[row].insert(roleName, value); const auto idx = index(row, 0); emit dataChanged(idx, idx, {m_roleForName.value(roleName)}); } // Change several cells on one row, notify with the union of their roles. void updateCells(int row, const QHash& changes) { QList roles; for (auto it = changes.cbegin(); it != changes.cend(); ++it) { m_rows[row].insert(it.key(), it.value()); roles.append(m_roleForName.value(it.key())); } const auto idx = index(row, 0); emit dataChanged(idx, idx, roles); } int roleFor(const QByteArray& name) const { return m_roleForName.value(name, -1); } private: QList> m_rows; QHash m_roleForName; QHash m_nameForRole; }; namespace { using Row = QHash; Row regularToken(const QString& key, const QString& balance = "0") { return {{"key", key}, {"symbol", key.toUpper()}, {"name", key}, {"enabledNetworkBalance", balance}}; } Row communityToken(const QString& key, const QString& communityId, const QString& balance = "0") { return {{"key", key}, {"symbol", key.toUpper()}, {"name", key}, {"communityId", communityId}, {"communityName", communityId}, {"enabledNetworkBalance", balance}}; } Row collectionToken(const QString& key, const QString& collectionUid, const QString& balance = "0") { return {{"key", key}, {"symbol", key.toUpper()}, {"name", key}, {"collectionUid", collectionUid}, {"collectionName", collectionUid}, {"enabledNetworkBalance", balance}}; } QStringList keysOf(QAbstractItemModel* model) { QStringList result; const auto keyRole = model->roleNames().key("key", -1); for (int i = 0; i < model->rowCount(); i++) result << model->data(model->index(i, 0), keyRole).toString(); return result; } QVariant dataForKey(QAbstractItemModel* model, const QString& key, const QByteArray& roleName) { const auto keyRole = model->roleNames().key("key", -1); const auto role = model->roleNames().key(roleName, -1); for (int i = 0; i < model->rowCount(); i++) { if (model->data(model->index(i, 0), keyRole).toString() == key) return model->data(model->index(i, 0), role); } return {}; } } // namespace class TestManageTokensController : public QObject { Q_OBJECT // Boilerplate: build a controller wired to `source` and run the initial parse. // Returns the controller; caller owns nothing extra (source outlives it). static void populate(ManageTokensController& controller, SourceModel& source) { controller.setProperty("sourceModel", QVariant::fromValue(&source)); QMetaObject::invokeMethod(&controller, "loadingFinished", Q_ARG(QString, QString())); } static QAbstractItemModel* model(ManageTokensController& c, const char* name) { return c.property(name).value(); } private slots: // --- Characterization: observable end-state that must survive the refactor --- void initialParsePartitionsByCommunity() { SourceModel source; source.resetWith({regularToken("eth"), communityToken("cat", "community_1"), regularToken("dai")}); ManageTokensController controller; populate(controller, source); QCOMPARE(keysOf(model(controller, "regularTokensModel")), (QStringList{"eth", "dai"})); QCOMPARE(keysOf(model(controller, "communityTokensModel")), (QStringList{"cat"})); QCOMPARE(model(controller, "hiddenTokensModel")->rowCount(), 0); } void communityGroupsModelReflectsChildCount() { SourceModel source; source.resetWith({communityToken("cat", "community_1"), communityToken("dog", "community_1"), communityToken("fox", "community_2")}); ManageTokensController controller; populate(controller, source); auto groups = model(controller, "communityTokenGroupsModel"); QCOMPARE(groups->rowCount(), 2); // group's childCount is exposed through the "enabledNetworkBalance" role QCOMPARE(dataForKey(groups, "cat", "enabledNetworkBalance").toInt(), 2); QCOMPARE(dataForKey(groups, "fox", "enabledNetworkBalance").toInt(), 1); } void collectionGroupsModelReflectsChildCount() { SourceModel source; source.resetWith({collectionToken("punk1", "punks"), collectionToken("punk2", "punks"), collectionToken("ape1", "apes")}); ManageTokensController controller; populate(controller, source); auto groups = model(controller, "collectionGroupsModel"); QCOMPARE(groups->rowCount(), 2); QCOMPARE(dataForKey(groups, "punk1", "enabledNetworkBalance").toInt(), 2); } void sourceResetReparses() { SourceModel source; source.resetWith({regularToken("eth"), regularToken("dai")}); ManageTokensController controller; populate(controller, source); QCOMPARE(model(controller, "regularTokensModel")->rowCount(), 2); source.resetWith({regularToken("btc")}); QCOMPARE(keysOf(model(controller, "regularTokensModel")), (QStringList{"btc"})); } void rowInsertedAppendsToRegularModel() { SourceModel source; source.resetWith({regularToken("eth")}); ManageTokensController controller; populate(controller, source); source.appendRow(regularToken("dai")); QCOMPARE(keysOf(model(controller, "regularTokensModel")), (QStringList{"eth", "dai"})); } // QAbstractItemModelTester validates the model contract (index ranges, signal // ordering, role validity) on every output model across a data-change storm. void outputModelsSatisfyModelContractThroughStorm() { SourceModel source; source.resetWith({regularToken("eth", "1"), communityToken("cat", "community_1", "2"), collectionToken("punk1", "punks", "3")}); ManageTokensController controller; const QList names{"regularTokensModel", "communityTokensModel", "communityTokenGroupsModel", "hiddenTokensModel", "hiddenCommunityTokenGroupsModel", "collectionGroupsModel", "hiddenCollectionGroupsModel"}; std::vector> testers; for (auto n : names) testers.push_back(std::make_unique(model(controller, n))); populate(controller, source); for (int i = 0; i < 5; i++) source.updateCell(0, "enabledNetworkBalance", QString::number(100 + i)); // Poll for the batched update to land rather than a fixed 1ms sleep. QTRY_COMPARE(dataForKey(model(controller, "regularTokensModel"), "eth", "enabledNetworkBalance").toString(), QStringLiteral("104")); } // --- New incremental behavior (fails before the fix, passes after) --- // A data-only dataChanged updates the affected token in place: the holding // model emits a narrow dataChanged and is NOT reset. void dataOnlyChangeUpdatesInPlaceWithoutReset() { SourceModel source; source.resetWith({regularToken("eth", "1"), regularToken("dai", "2")}); ManageTokensController controller; populate(controller, source); auto regular = model(controller, "regularTokensModel"); QSignalSpy resetSpy(regular, &QAbstractItemModel::modelReset); QSignalSpy changedSpy(regular, &QAbstractItemModel::dataChanged); source.updateCell(0, "enabledNetworkBalance", QStringLiteral("42")); // Wait for the batched dataChanged to land (the co-occurring positive), then // assert the negatives: a QTRY cannot prove "no reset happened". QTRY_VERIFY(changedSpy.count() >= 1); QCOMPARE(resetSpy.count(), 0); QCOMPARE(dataForKey(regular, "eth", "enabledNetworkBalance").toString(), QStringLiteral("42")); // untouched row keeps its value and the row set is unchanged QCOMPARE(keysOf(regular), (QStringList{"eth", "dai"})); QCOMPARE(dataForKey(regular, "dai", "enabledNetworkBalance").toString(), QStringLiteral("2")); } // A storm of data-only dataChanged events coalesces into a single batched // update instead of one full re-parse per event. void stormOfDataChangesCoalesces() { SourceModel source; source.resetWith({regularToken("eth", "0")}); ManageTokensController controller; populate(controller, source); auto regular = model(controller, "regularTokensModel"); QSignalSpy resetSpy(regular, &QAbstractItemModel::modelReset); QSignalSpy changedSpy(regular, &QAbstractItemModel::dataChanged); constexpr int M = 20; for (int i = 0; i < M; i++) source.updateCell(0, "enabledNetworkBalance", QString::number(i)); // Poll for the final value to land, THEN assert the negatives (exactly one // coalesced dataChanged, no reset). Polling the count directly could pass at // the first emit and miss a later increment if coalescing were broken. QTRY_COMPARE(dataForKey(regular, "eth", "enabledNetworkBalance").toString(), QString::number(M - 1)); QCOMPARE(resetSpy.count(), 0); QCOMPARE(changedSpy.count(), 1); // coalesced } // A data-only change to a regular token must not churn the community/hidden // models at all. void dataOnlyChangeDoesNotTouchOtherModels() { SourceModel source; source.resetWith({regularToken("eth", "1"), communityToken("cat", "community_1", "2")}); ManageTokensController controller; populate(controller, source); auto community = model(controller, "communityTokensModel"); auto hidden = model(controller, "hiddenTokensModel"); QSignalSpy communityReset(community, &QAbstractItemModel::modelReset); QSignalSpy communityChanged(community, &QAbstractItemModel::dataChanged); QSignalSpy hiddenReset(hidden, &QAbstractItemModel::modelReset); source.updateCell(0, "enabledNetworkBalance", QStringLiteral("99")); // The change targets the regular "eth" token; wait for THAT to land (the // co-occurring positive), then assert the community/hidden models were not // touched. A QTRY on the spies could not prove they stay at zero. QTRY_COMPARE(dataForKey(model(controller, "regularTokensModel"), "eth", "enabledNetworkBalance").toString(), QStringLiteral("99")); QCOMPARE(communityReset.count(), 0); QCOMPARE(communityChanged.count(), 0); QCOMPARE(hiddenReset.count(), 0); // community token unchanged QCOMPARE(dataForKey(community, "cat", "enabledNetworkBalance").toString(), QStringLiteral("2")); } // A structural change (token moves communities) still lands correctly via the // full-reparse fallback: end-state partitioning must be right. void structuralChangeRepartitionsToken() { SourceModel source; source.resetWith({regularToken("eth"), regularToken("cat")}); ManageTokensController controller; populate(controller, source); QCOMPARE(keysOf(model(controller, "regularTokensModel")), (QStringList{"eth", "cat"})); QCOMPARE(model(controller, "communityTokensModel")->rowCount(), 0); // "cat" becomes a community token source.updateCells(1, {{"communityId", "community_1"}, {"communityName", "community_1"}}); // Poll for the repartition to complete (single reparse moves cat across). QTRY_COMPARE(keysOf(model(controller, "regularTokensModel")), (QStringList{"eth"})); QCOMPARE(keysOf(model(controller, "communityTokensModel")), (QStringList{"cat"})); } }; QTEST_GUILESS_MAIN(TestManageTokensController) #include "tst_ManageTokensController.moc"