mirror of
https://github.com/logos-co/logos-app-poc.git
synced 2026-08-27 18:01:13 +00:00
245 lines
9.8 KiB
C++
245 lines
9.8 KiB
C++
// 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 <QSignalSpy>
|
|
#include <QVariantList>
|
|
#include <QVariantMap>
|
|
#include <QtTest/QtTest>
|
|
|
|
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<QByteArray> required{
|
|
"name", "label", "description", "category", "type",
|
|
"version", "iconPath", "installType",
|
|
"isLoaded", "isMainUi", "hasMissingDeps",
|
|
"statusText", "cpu", "memory",
|
|
};
|
|
QHash<QByteArray, bool> 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");
|
|
}
|
|
|
|
// ── 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"
|