// srcdeps: ModuleInstanceModel.cpp // // Unit tests for ModuleInstanceModel — the QAbstractListModel that adapts // the QVariantList returned by uiModules()/coreModules() into named roles // for the Settings inspectors. Same test harness as apps_model_test: // plain QtTest, glob-discovered by tests/CMakeLists.txt. #include "ModuleInstanceModel.h" #include #include #include #include namespace { QVariantMap makeModule(const QString& name, bool isLoaded = false, const QVariantMap& extras = {}) { QVariantMap m; m.insert(QStringLiteral("name"), name); m.insert(QStringLiteral("isLoaded"), isLoaded); for (auto it = extras.cbegin(); it != extras.cend(); ++it) m.insert(it.key(), it.value()); return m; } int roleFor(const ModuleInstanceModel& model, const QByteArray& name) { const auto roles = model.roleNames(); for (auto it = roles.cbegin(); it != roles.cend(); ++it) { if (it.value() == name) return it.key(); } return -1; } QVariant fieldFor(const ModuleInstanceModel& model, int row, const QByteArray& name) { const int role = roleFor(model, name); Q_ASSERT(role >= 0); return model.data(model.index(row), role); } } // namespace class ModuleInstanceModelTest : public QObject { Q_OBJECT private slots: // ── roleNames contract ──────────────────────────────────────────── // QML delegates read roles by name (`rowItem.label`, `rowItem.isLoaded`) // — renaming any of these silently breaks every inspector binding. void roleNames_stable() { ModuleInstanceModel model; const auto roles = model.roleNames(); const QList required{ "name", "label", "description", "category", "type", "version", "iconPath", "installType", "isLoaded", "isMainUi", "hasMissingDeps", "statusText", "cpu", "memory", }; QHash seen; for (auto it = roles.cbegin(); it != roles.cend(); ++it) seen.insert(it.value(), true); for (const QByteArray& r : required) QVERIFY2(seen.contains(r), qPrintable("missing role: " + r)); } // ── Basic population + role dispatch ────────────────────────────── void replaceRows_populates_normalised_fields() { ModuleInstanceModel model; model.replaceRows({ makeModule("waku", /*loaded=*/true, { {"displayName", "Waku"}, {"description", "The transport"}, {"category", "networking"}, {"type", "core"}, {"version", "1.2.3"}, {"installType", "user"}, }), }); QCOMPARE(model.rowCount(), 1); QCOMPARE(fieldFor(model, 0, "name").toString(), "waku"); QCOMPARE(fieldFor(model, 0, "label").toString(), "Waku"); QCOMPARE(fieldFor(model, 0, "description").toString(), "The transport"); QCOMPARE(fieldFor(model, 0, "category").toString(), "networking"); QCOMPARE(fieldFor(model, 0, "type").toString(), "core"); QCOMPARE(fieldFor(model, 0, "version").toString(), "1.2.3"); QCOMPARE(fieldFor(model, 0, "installType").toString(), "user"); QCOMPARE(fieldFor(model, 0, "isLoaded").toBool(), true); QCOMPARE(fieldFor(model, 0, "statusText").toString(), "Loaded"); } // displayName absent → label falls back to name (used everywhere QML // renders "displayName || name"). void label_falls_back_to_name_when_displayName_missing() { ModuleInstanceModel model; model.replaceRows({ makeModule("no_display") }); QCOMPARE(fieldFor(model, 0, "label").toString(), "no_display"); } // ── statusText derivation ───────────────────────────────────────── void statusText_reflects_priority_mainUi_missingDeps_loaded() { ModuleInstanceModel model; model.replaceRows({ makeModule("plain"), // not loaded, not main, no missing makeModule("loaded", /*loaded=*/true), makeModule("main", /*loaded=*/true, {{"isMainUi", true}}), makeModule("broken", /*loaded=*/true, {{"hasMissingDeps", true}}), }); QCOMPARE(fieldFor(model, 0, "statusText").toString(), "Not loaded"); QCOMPARE(fieldFor(model, 1, "statusText").toString(), "Loaded"); // Main UI wins over Loaded — the badge should read "Main UI" for main_ui. QCOMPARE(fieldFor(model, 2, "statusText").toString(), "Main UI"); // Missing deps wins over Loaded (blocks the module regardless of load state). QCOMPARE(fieldFor(model, 3, "statusText").toString(), "Missing deps"); } // Calling an installed-but-out-of-range dependency "Missing deps" points // the user at an install they have already performed. The wording lives // here because ModuleStatusBadge reads only `statusText`. void statusText_says_version_conflict_when_nothing_is_actually_missing() { ModuleInstanceModel model; model.replaceRows({ makeModule("mismatched", /*loaded=*/false, {{"hasMissingDeps", true}, {"depBlockKind", "mismatch"}}), }); QCOMPARE(fieldFor(model, 0, "statusText").toString(), "Version conflict"); } // A third state with a third remedy: no version of what is on disk is the // package the module needs, so neither of the other two words is true. void statusText_says_signer_conflict_for_a_wrong_publisher_dependency() { ModuleInstanceModel model; model.replaceRows({ makeModule("wrongpub", /*loaded=*/false, {{"hasMissingDeps", true}, {"depBlockKind", "signer"}}), }); QCOMPARE(fieldFor(model, 0, "statusText").toString(), "Signer conflict"); } // "mixed" means at least one dependency really is absent, and that is the // fact the user has to act on first — so it keeps the absence wording. void statusText_keeps_missing_deps_for_a_mixed_set() { ModuleInstanceModel model; model.replaceRows({ makeModule("both", /*loaded=*/false, {{"hasMissingDeps", true}, {"depBlockKind", "mixed"}}), makeModule("absent", /*loaded=*/false, {{"hasMissingDeps", true}, {"depBlockKind", "absent"}}), // A payload predating depBlockKind must read as it did before. makeModule("legacy", /*loaded=*/false, {{"hasMissingDeps", true}}), }); QCOMPARE(fieldFor(model, 0, "statusText").toString(), "Missing deps"); QCOMPARE(fieldFor(model, 1, "statusText").toString(), "Missing deps"); QCOMPARE(fieldFor(model, 2, "statusText").toString(), "Missing deps"); } // A row can go absent -> mismatch with hasMissingDeps never moving. // replaceRows' fast path patches in place and SKIPS a row whose diff mask // is empty, so omitting depBlockKind from diffRoles does not merely miss a // dataChanged — it leaves the old row in the model permanently. void statusText_refreshes_when_only_the_block_kind_moved() { ModuleInstanceModel model; model.replaceRows({ makeModule("app", /*loaded=*/false, {{"hasMissingDeps", true}, {"depBlockKind", "absent"}}), }); QCOMPARE(fieldFor(model, 0, "statusText").toString(), "Missing deps"); QSignalSpy spy(&model, &ModuleInstanceModel::dataChanged); model.replaceRows({ makeModule("app", /*loaded=*/false, {{"hasMissingDeps", true}, {"depBlockKind", "mismatch"}}), }); QCOMPARE(fieldFor(model, 0, "statusText").toString(), "Version conflict"); QCOMPARE(spy.count(), 1); const auto roles = spy.at(0).at(2).value>(); QVERIFY(roles.contains(ModuleInstanceModel::StatusTextRole)); } // ── Numeric coercion ────────────────────────────────────────────── // Core stats arrive as either doubles or strings depending on what the // module reported. The proxy sorts numerically — coerce up front so the // roles are always numeric. void numeric_stats_coerced_from_string() { ModuleInstanceModel model; model.replaceRows({ makeModule("mod", /*loaded=*/true, { {"cpu", "12.5"}, {"memory", "48.0"}, }), }); QCOMPARE(fieldFor(model, 0, "cpu").toDouble(), 12.5); QCOMPARE(fieldFor(model, 0, "memory").toDouble(), 48.0); } void numeric_stats_default_zero_when_unparseable() { ModuleInstanceModel model; model.replaceRows({ makeModule("mod", /*loaded=*/true, {{"cpu", "nope"}}), }); QCOMPARE(fieldFor(model, 0, "cpu").toDouble(), 0.0); } // ── Empty rows / skipping ───────────────────────────────────────── void empty_name_is_skipped() { ModuleInstanceModel model; QVariantMap noName; noName.insert("isLoaded", true); model.replaceRows({ noName, makeModule("real") }); QCOMPARE(model.rowCount(), 1); QCOMPARE(fieldFor(model, 0, "name").toString(), "real"); } void replaceRows_empty_clears_model() { ModuleInstanceModel model; model.replaceRows({ makeModule("a"), makeModule("b") }); QCOMPARE(model.rowCount(), 2); model.replaceRows({}); QCOMPARE(model.rowCount(), 0); } // ── Patch-in-place fast path ────────────────────────────────────── // The 2s core-stats poll re-runs replaceRows with the same rows in the // same order, only CPU/memory changed. If we reset the model every tick, // the table's scroll position resets and every delegate rebuilds — a // visible flicker. The patch path is the fix; verify it emits // dataChanged on the moving roles only (not modelReset). void patchInPlace_when_names_stable_emits_dataChanged_only() { ModuleInstanceModel model; model.replaceRows({ makeModule("a", true, {{"cpu", "1.0"}, {"memory", "10.0"}}), makeModule("b", true, {{"cpu", "2.0"}, {"memory", "20.0"}}), }); QSignalSpy dataSpy(&model, &QAbstractItemModel::dataChanged); QSignalSpy resetSpy(&model, &QAbstractItemModel::modelReset); // Same identity + order, only stats changed. model.replaceRows({ makeModule("a", true, {{"cpu", "1.5"}, {"memory", "10.0"}}), makeModule("b", true, {{"cpu", "2.0"}, {"memory", "25.0"}}), }); QCOMPARE(resetSpy.count(), 0); // One dataChanged per changed row. QCOMPARE(dataSpy.count(), 2); QCOMPARE(fieldFor(model, 0, "cpu").toDouble(), 1.5); QCOMPARE(fieldFor(model, 1, "memory").toDouble(), 25.0); } // Identical input on a re-tick with zero changes: no signals at all. // Prevents QML delegates from re-rendering for nothing. void patchInPlace_idempotent_zero_signals_on_identical_input() { ModuleInstanceModel model; model.replaceRows({ makeModule("a", true, {{"cpu", "1.0"}}) }); QSignalSpy dataSpy(&model, &QAbstractItemModel::dataChanged); QSignalSpy resetSpy(&model, &QAbstractItemModel::modelReset); model.replaceRows({ makeModule("a", true, {{"cpu", "1.0"}}) }); QCOMPARE(resetSpy.count(), 0); QCOMPARE(dataSpy.count(), 0); } // Row identity change (add/remove/reorder) falls back to a full reset. void identityChange_triggers_modelReset() { ModuleInstanceModel model; model.replaceRows({ makeModule("a"), makeModule("b") }); QSignalSpy resetSpy(&model, &QAbstractItemModel::modelReset); // A different row added at position 0 — order changed. model.replaceRows({ makeModule("z"), makeModule("a"), makeModule("b") }); QCOMPARE(resetSpy.count(), 1); QCOMPARE(model.rowCount(), 3); } // ── Boolean state derivations ───────────────────────────────────── void isLoaded_reflects_input() { ModuleInstanceModel model; model.replaceRows({ makeModule("off", /*loaded=*/false), makeModule("on", /*loaded=*/true), }); QCOMPARE(fieldFor(model, 0, "isLoaded").toBool(), false); QCOMPARE(fieldFor(model, 1, "isLoaded").toBool(), true); } }; QTEST_GUILESS_MAIN(ModuleInstanceModelTest) #include "module_instance_model_test.moc"