// srcdeps: AppsModel.cpp InstallEnums.cpp InstallRegistry.cpp // // Unit tests for AppsModel's per-row install-status compute. Built and // driven the same way the sandbox-test is — plain QtTest, standalone // CMakeLists in this directory, run via `nix build .#unit-tests`. No // logos-test-framework dependency: AppsModel is a pure Qt model, not a // Logos module. // // What's tested: recomputeInstallStatus + markInstalled + setMissingDeps // + replaceCatalog via the public model API. No IPC, no display, no // PackageCoordinator. Run locally: // // nix build .#unit-tests -L #include "AppsModel.h" #include "InstallEnums.h" #include "InstallRegistry.h" #include #include #include namespace { // ── DSL: small builders so each test reads like its scenario ────────────── // Bridge to the pre-InstallRegistry AppsModel API. Older tests called // model.setInstallStage(name, stage[, error]); today AppsModel reads stage // via a separate InstallRegistry. These helpers keep the tests readable // without touching the InstallRegistry contract line-by-line: // - `stage(reg, name, InstallStage::Installing)` — begin() + setStage() // - `stageFailed(reg, name, err)` — begin() + fail() // InstallRegistry::setStage/fail no-op if the name isn't already begun, // so begin() first (using empty target strings — not part of the assertion). inline void stage(InstallRegistry& reg, const QString& name, InstallStage::Value s) { reg.begin(name, /*targetVersion=*/QString(), /*targetHash=*/QString(), /*startedByTopLevel=*/name); reg.setStage(name, s); } inline void stageFailed(InstallRegistry& reg, const QString& name, const QString& error) { reg.begin(name, /*targetVersion=*/QString(), /*targetHash=*/QString(), /*startedByTopLevel=*/name); reg.fail(name, error); } QVariantMap makeDep(const QString& name, const QString& version = {}) { QVariantMap d; d.insert(QStringLiteral("name"), name); if (!version.isEmpty()) d.insert(QStringLiteral("version"), version); return d; } // Shape returned by package_manager.getInstalledPackages() — matches the // InstalledPackage JSON in logos-package-manager. Only the fields // mergeLocalOnlyInstalled reads are populated here. QVariantMap makeInstalledPackage(const QString& name, const QString& version, const QString& rootHash, const QString& category = {}, const QString& installType = QStringLiteral("user")) { QVariantMap hashes; hashes.insert(QStringLiteral("root"), rootHash); QVariantMap pkg; pkg.insert(QStringLiteral("name"), name); pkg.insert(QStringLiteral("version"), version); pkg.insert(QStringLiteral("hashes"), hashes); pkg.insert(QStringLiteral("installType"), installType); if (!category.isEmpty()) pkg.insert(QStringLiteral("category"), category); return pkg; } QVariantMap makeCatalogRow(const QString& repo, const QString& name, const QString& version, const QString& rootHash, const QVariantList& deps = {}) { // Matches the real catalog index shape used by logos-modules-release // (see https://raw.githubusercontent.com/dlipicar/logos-modules-release/ // refs/heads/main/logos-repo.json → indexUrl). Version lives INSIDE // manifest, rootHash sits at the top of the version entry. Earlier // tests put `version` at the top of the version entry which let // AppsModel's version-in-manifest read miss silently — the prod // bug we're now catching. QVariantMap manifest; manifest.insert(QStringLiteral("version"), version); manifest.insert(QStringLiteral("dependencies"), deps); QVariantMap versionEntry; versionEntry.insert(QStringLiteral("rootHash"), rootHash); versionEntry.insert(QStringLiteral("manifest"), manifest); QVariantMap row; row.insert(QStringLiteral("name"), name); row.insert(QStringLiteral("repositoryUrl"), repo); row.insert(QStringLiteral("versions"), QVariantList{versionEntry}); return row; } // Read InstallStatus for a (name, repo) row through the public role API. InstallStatus::Value statusOf(const AppsModel& model, const QString& name, const QString& repo) { int statusRole = -1, nameRole = -1, repoRole = -1; const auto& roles = model.roleNames(); for (auto it = roles.cbegin(); it != roles.cend(); ++it) { if (it.value() == "installStatus") statusRole = it.key(); else if (it.value() == "name") nameRole = it.key(); else if (it.value() == "repositoryUrl") repoRole = it.key(); } Q_ASSERT(statusRole >= 0 && nameRole >= 0 && repoRole >= 0); const int n = model.rowCount(); for (int i = 0; i < n; ++i) { const QModelIndex idx = model.index(i); if (model.data(idx, nameRole).toString() != name) continue; if (model.data(idx, repoRole).toString() != repo) continue; return static_cast( model.data(idx, statusRole).toInt()); } qFatal("statusOf: row not found for (%s, %s)", qPrintable(name), qPrintable(repo)); return InstallStatus::NotInstalled; } // Read iconUrl for a (name, repo) row through the public role API. QString iconUrlOf(const AppsModel& model, const QString& name, const QString& repo) { int iconRole = -1, nameRole = -1, repoRole = -1; const auto& roles = model.roleNames(); for (auto it = roles.cbegin(); it != roles.cend(); ++it) { if (it.value() == "iconUrl") iconRole = it.key(); else if (it.value() == "name") nameRole = it.key(); else if (it.value() == "repositoryUrl") repoRole = it.key(); } Q_ASSERT(iconRole >= 0 && nameRole >= 0 && repoRole >= 0); const int n = model.rowCount(); for (int i = 0; i < n; ++i) { const QModelIndex idx = model.index(i); if (model.data(idx, nameRole).toString() != name) continue; if (model.data(idx, repoRole).toString() != repo) continue; return model.data(idx, iconRole).toString(); } qFatal("iconUrlOf: row not found for (%s, %s)", qPrintable(name), qPrintable(repo)); return {}; } } // namespace class AppsModelTest : public QObject { Q_OBJECT private slots: // ── Fresh state ───────────────────────────────────────────────────── void notInstalledByDefault() { AppsModel model; model.replaceCatalog({ makeCatalogRow("repo1", "wallet_ui", "1.0", "H_ui"), }); QCOMPARE(statusOf(model, "wallet_ui", "repo1"), InstallStatus::NotInstalled); } // ── Single repo, identity & version ───────────────────────────────── void singleRepoInstalled() { AppsModel model; model.replaceCatalog({ makeCatalogRow("repo1", "wallet_ui", "1.0", "H_ui"), }); model.markInstalled("wallet_ui", "1.0", "H_ui"); QCOMPARE(statusOf(model, "wallet_ui", "repo1"), InstallStatus::Installed); } void singleRepoUpgradeAvailable() { AppsModel model; model.replaceCatalog({ makeCatalogRow("repo1", "wallet_ui", "2.0", "H_v2"), }); model.markInstalled("wallet_ui", "1.0", "H_v1"); QCOMPARE(statusOf(model, "wallet_ui", "repo1"), InstallStatus::UpgradeAvailable); } void singleRepoDowngradeAvailable() { AppsModel model; model.replaceCatalog({ makeCatalogRow("repo1", "wallet_ui", "1.0", "H_v1"), }); model.markInstalled("wallet_ui", "2.0", "H_v2"); QCOMPARE(statusOf(model, "wallet_ui", "repo1"), InstallStatus::DowngradeAvailable); } void singleRepoDifferentHash_topLevel() { AppsModel model; model.replaceCatalog({ makeCatalogRow("repo1", "wallet_ui", "1.0", "H_repo1"), }); // Same version, different binary. model.markInstalled("wallet_ui", "1.0", "H_otherBuild"); QCOMPARE(statusOf(model, "wallet_ui", "repo1"), InstallStatus::DifferentHash); } // ── Multi-repo: identical builds → both Installed ─────────────────── void twoReposSameHash_bothInstalled() { AppsModel model; model.replaceCatalog({ makeCatalogRow("repo1", "wallet_ui", "1.0", "H_shared"), makeCatalogRow("repo2", "wallet_ui", "1.0", "H_shared"), }); model.markInstalled("wallet_ui", "1.0", "H_shared"); QCOMPARE(statusOf(model, "wallet_ui", "repo1"), InstallStatus::Installed); QCOMPARE(statusOf(model, "wallet_ui", "repo2"), InstallStatus::Installed); } // ── Multi-repo: different top-level hash ──────────────────────────── void twoReposDifferentTopLevelHash() { AppsModel model; model.replaceCatalog({ makeCatalogRow("repo1", "wallet_ui", "1.0", "H1"), makeCatalogRow("repo2", "wallet_ui", "1.0", "H2"), }); model.markInstalled("wallet_ui", "1.0", "H1"); QCOMPARE(statusOf(model, "wallet_ui", "repo1"), InstallStatus::Installed); QCOMPARE(statusOf(model, "wallet_ui", "repo2"), InstallStatus::DifferentHash); } // ── Multi-repo: same top-level, different dep hash ────────────────── // The reported bug: wallet_ui shared between repos, wallet_modules // differs. Installing from repo1 must mark repo2's tile Reinstall. void twoReposDifferentDepHash() { AppsModel model; model.replaceCatalog({ makeCatalogRow("repo1", "wallet_ui", "1.0", "H_ui_shared", { makeDep("wallet_modules") }), makeCatalogRow("repo2", "wallet_ui", "1.0", "H_ui_shared", { makeDep("wallet_modules") }), makeCatalogRow("repo1", "wallet_modules", "1.0", "H_mods_repo1"), makeCatalogRow("repo2", "wallet_modules", "1.0", "H_mods_repo2"), }); model.markInstalled("wallet_ui", "1.0", "H_ui_shared"); model.markInstalled("wallet_modules", "1.0", "H_mods_repo1"); QCOMPARE(statusOf(model, "wallet_ui", "repo1"), InstallStatus::Installed); QCOMPARE(statusOf(model, "wallet_ui", "repo2"), InstallStatus::DifferentHash); QCOMPARE(statusOf(model, "wallet_modules", "repo1"), InstallStatus::Installed); QCOMPARE(statusOf(model, "wallet_modules", "repo2"), InstallStatus::DifferentHash); } // ── Partial install (deps missing) ────────────────────────────────── void missingDepsForcesNotInstalled() { AppsModel model; model.replaceCatalog({ makeCatalogRow("repo1", "wallet_ui", "1.0", "H_ui", { makeDep("wallet_modules") }), makeCatalogRow("repo2", "wallet_ui", "1.0", "H_ui", { makeDep("wallet_modules") }), makeCatalogRow("repo1", "wallet_modules", "1.0", "H_mods_repo1"), makeCatalogRow("repo2", "wallet_modules", "1.0", "H_mods_repo2"), }); model.markInstalled("wallet_ui", "1.0", "H_ui"); model.setMissingDeps("wallet_ui", { "wallet_modules" }); QCOMPARE(statusOf(model, "wallet_ui", "repo1"), InstallStatus::NotInstalled); QCOMPARE(statusOf(model, "wallet_ui", "repo2"), InstallStatus::NotInstalled); } // ── markInstalled cascades to dependents ──────────────────────────── void markInstalledCascadesToDependents() { AppsModel model; model.replaceCatalog({ makeCatalogRow("repo1", "wallet_ui", "1.0", "H_ui", { makeDep("wallet_modules") }), makeCatalogRow("repo2", "wallet_ui", "1.0", "H_ui", { makeDep("wallet_modules") }), makeCatalogRow("repo1", "wallet_modules", "1.0", "H_mods_repo1"), makeCatalogRow("repo2", "wallet_modules", "1.0", "H_mods_repo2"), }); model.markInstalled("wallet_ui", "1.0", "H_ui"); model.markInstalled("wallet_modules", "1.0", "H_mods_repo1"); QCOMPARE(statusOf(model, "wallet_ui", "repo1"), InstallStatus::Installed); QCOMPARE(statusOf(model, "wallet_ui", "repo2"), InstallStatus::DifferentHash); // Swap wallet_modules to repo2's hash. model.markInstalled("wallet_modules", "1.0", "H_mods_repo2"); QCOMPARE(statusOf(model, "wallet_ui", "repo1"), InstallStatus::DifferentHash); QCOMPARE(statusOf(model, "wallet_ui", "repo2"), InstallStatus::Installed); QCOMPARE(statusOf(model, "wallet_modules", "repo1"), InstallStatus::DifferentHash); QCOMPARE(statusOf(model, "wallet_modules", "repo2"), InstallStatus::Installed); } // ── Uninstall ─────────────────────────────────────────────────────── void uninstallReturnsToNotInstalled() { AppsModel model; model.replaceCatalog({ makeCatalogRow("repo1", "wallet_ui", "1.0", "H_ui"), makeCatalogRow("repo2", "wallet_ui", "1.0", "H_ui"), }); model.markInstalled("wallet_ui", "1.0", "H_ui"); QCOMPARE(statusOf(model, "wallet_ui", "repo1"), InstallStatus::Installed); model.markInstalled("wallet_ui", "", ""); QCOMPARE(statusOf(model, "wallet_ui", "repo1"), InstallStatus::NotInstalled); QCOMPARE(statusOf(model, "wallet_ui", "repo2"), InstallStatus::NotInstalled); } // ── Resolver-fallback: dep only in one repo ───────────────────────── void resolverFallbackDepNotInThisRepo() { AppsModel model; model.replaceCatalog({ makeCatalogRow("repo1", "wallet_ui", "1.0", "H_ui", { makeDep("wallet_modules") }), makeCatalogRow("repo2", "wallet_ui", "1.0", "H_ui", { makeDep("wallet_modules") }), makeCatalogRow("repo1", "wallet_modules", "1.0", "H_mods"), }); model.markInstalled("wallet_ui", "1.0", "H_ui"); model.markInstalled("wallet_modules", "1.0", "H_mods"); QCOMPARE(statusOf(model, "wallet_ui", "repo1"), InstallStatus::Installed); // repo2 doesn't publish wallet_modules — can't compare hashes; top- // level matches, so we don't downgrade. QCOMPARE(statusOf(model, "wallet_ui", "repo2"), InstallStatus::Installed); } // ── Regression: version lives in manifest.version, not at the top ─ // Previously AppsModel read versions[i].version, which is empty for the // real catalog shape (the index puts version inside manifest). That // made latestVersion = "" → recomputeInstallStatus's best-effort branch // returned Installed for every row regardless of state, skipping // version/hash/dep checks entirely. This test constructs a row by hand // (no helper) with version ONLY in manifest, then asserts the model // picks it up — older installedVersion ⇒ UpgradeAvailable, only // reachable when latestVersion is non-empty. void versionReadFromManifestNotTopLevel() { QVariantMap manifest; manifest.insert(QStringLiteral("version"), "1.0.0"); manifest.insert(QStringLiteral("dependencies"), QVariantList{}); QVariantMap versionEntry; // Deliberately omit top-level "version" — that's how the real index // looks. rootHash sits at the top of the version entry. versionEntry.insert(QStringLiteral("rootHash"), "H_v1"); versionEntry.insert(QStringLiteral("manifest"), manifest); QVariantMap row; row.insert(QStringLiteral("name"), "wallet_ui"); row.insert(QStringLiteral("repositoryUrl"), "repo1"); row.insert(QStringLiteral("versions"), QVariantList{versionEntry}); AppsModel model; model.replaceCatalog({ row }); // Installed at older version. With the bug, latestVersion was "" // so the compute returned Installed (best-effort). With the fix, // latestVersion="1.0.0", installedVersion="0.9.0" → UpgradeAvailable. model.markInstalled("wallet_ui", "0.9.0", "H_v0"); QCOMPARE(statusOf(model, "wallet_ui", "repo1"), InstallStatus::UpgradeAvailable); // Same version, different hash → DifferentHash. Same story — // unreachable with the bug because the function bailed before // the hash check. model.markInstalled("wallet_ui", "1.0.0", "H_otherBuild"); QCOMPARE(statusOf(model, "wallet_ui", "repo1"), InstallStatus::DifferentHash); } // ── End-to-end: actual logos-modules-release shape ───────────────── // Pins the exact (repo, package) layout the field hit — two repos // each publishing wallet_ui (depends on wallet_module) at the same // version but DIFFERENT root hashes. Installing repo1's binaries // must leave repo2's tile in DifferentHash. The row data here mirrors // (key-for-key) the JSON shape served by // https://raw.githubusercontent.com/dlipicar/logos-modules-release/ // refs/heads/main/logos-repo.json → indexUrl, so any future schema // drift surfaces here first. void realCatalogShape_walletAcrossTwoRepos() { auto makeRealShape = [](const QString& repo, const QString& name, const QString& version, const QString& rootHash, const QVariantList& deps) { QVariantMap manifest; manifest.insert(QStringLiteral("version"), version); manifest.insert(QStringLiteral("dependencies"), deps); // The real index also nests hashes here under // manifest.hashes.root — same value as the top-level rootHash. QVariantMap hashes; hashes.insert(QStringLiteral("root"), rootHash); manifest.insert(QStringLiteral("hashes"), hashes); QVariantMap versionEntry; versionEntry.insert(QStringLiteral("rootHash"), rootHash); versionEntry.insert(QStringLiteral("releasedAt"), "2025-01-01"); versionEntry.insert(QStringLiteral("size"), QVariant::fromValue(123456)); versionEntry.insert(QStringLiteral("url"), "https://example/pkg.lgx"); versionEntry.insert(QStringLiteral("sha256"), rootHash); versionEntry.insert(QStringLiteral("manifest"), manifest); QVariantMap row; row.insert(QStringLiteral("name"), name); row.insert(QStringLiteral("repositoryUrl"), repo); row.insert(QStringLiteral("description"), ""); row.insert(QStringLiteral("type"), name.endsWith("_ui") ? "ui_qml" : "core"); row.insert(QStringLiteral("category"), ""); row.insert(QStringLiteral("versions"), QVariantList{ versionEntry }); return row; }; AppsModel model; model.replaceCatalog({ // repo1's wallet_ui depends on wallet_module — strings, like // the real manifest uses (["wallet_module"]). makeRealShape("repo1", "wallet_ui", "1.0.0", "831f345bd9c9bc1b204be74b2b4b9b7f0b306aa0663662f15e18cd2244a78854", QVariantList{ QVariant("wallet_module") }), makeRealShape("repo1", "wallet_module", "1.0.1", "cf2f3db583f3ba9e60893ce8898d2400e769583166df00b1d90e032351f7033f", QVariantList{}), // repo2 publishes the same packages but at different hashes // (the multi-repo case the user reported). makeRealShape("repo2", "wallet_ui", "1.0.0", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", QVariantList{ QVariant("wallet_module") }), makeRealShape("repo2", "wallet_module", "1.0.1", "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", QVariantList{}), }); // User installs the wallet from repo1. model.markInstalled("wallet_ui", "1.0.0", "831f345bd9c9bc1b204be74b2b4b9b7f0b306aa0663662f15e18cd2244a78854"); model.markInstalled("wallet_module", "1.0.1", "cf2f3db583f3ba9e60893ce8898d2400e769583166df00b1d90e032351f7033f"); QCOMPARE(statusOf(model, "wallet_ui", "repo1"), InstallStatus::Installed); QCOMPARE(statusOf(model, "wallet_module", "repo1"), InstallStatus::Installed); // The bug we just fixed: this row was returning Installed in // production because latestVersion read empty and the compute // bailed before checking hashes. QCOMPARE(statusOf(model, "wallet_ui", "repo2"), InstallStatus::DifferentHash); QCOMPARE(statusOf(model, "wallet_module", "repo2"), InstallStatus::DifferentHash); } // ── missingDeps cleared → flips back to Installed ─────────────────── void missingDepsClearedFlipsBackToInstalled() { AppsModel model; model.replaceCatalog({ makeCatalogRow("repo1", "wallet_ui", "1.0", "H_ui", { makeDep("wallet_modules") }), makeCatalogRow("repo1", "wallet_modules", "1.0", "H_mods"), }); model.markInstalled("wallet_ui", "1.0", "H_ui"); model.setMissingDeps("wallet_ui", { "wallet_modules" }); QCOMPARE(statusOf(model, "wallet_ui", "repo1"), InstallStatus::NotInstalled); model.markInstalled("wallet_modules", "1.0", "H_mods"); model.setMissingDeps("wallet_ui", {}); QCOMPARE(statusOf(model, "wallet_ui", "repo1"), InstallStatus::Installed); } // ── Regression: setResolverOverlay must pin (repo, name) ──────────── // Bug: with two repos publishing the same name, the second open of the // Add Application dialog left the clicked repo's rows with action="" // because setResolverOverlay used single-arg rowOf(name) and always // landed on the first-inserted row. PackageRowDelegate defaults action // empty → "Installed" badge, so the dialog showed "Installed" tags on // BOTH dep rows for a fresh install in repo2. void resolverOverlayPinsByRepo() { AppsModel model; model.replaceCatalog({ makeCatalogRow("repo1", "wallet_ui", "1.0", "H_ui1", { makeDep("wallet_module") }), makeCatalogRow("repo1", "wallet_module", "1.0", "H_mod1"), makeCatalogRow("repo2", "wallet_ui", "1.0", "H_ui2", { makeDep("wallet_module") }), makeCatalogRow("repo2", "wallet_module", "1.0", "H_mod2"), }); // Simulate the SECOND open of the dialog: click on repo2's tile. // The resolver result targets (wallet_ui, repo2) and // (wallet_module, repo2). AppsModel::ResolverRow top; top.name = "wallet_ui"; top.repositoryUrl = "repo2"; top.action = "install"; top.toVersion = "1.0"; top.isTopLevel = true; AppsModel::ResolverRow dep; dep.name = "wallet_module"; dep.repositoryUrl = "repo2"; dep.action = "install"; dep.toVersion = "1.0"; model.setResolverOverlay({top, dep}); // The action role must land on the repo2 rows so the dialog reads // "Install", not the default "Installed". int actionRole = -1, nameRole = -1, repoRole = -1; const auto& roles = model.roleNames(); for (auto it = roles.cbegin(); it != roles.cend(); ++it) { if (it.value() == "action") actionRole = it.key(); else if (it.value() == "name") nameRole = it.key(); else if (it.value() == "repositoryUrl") repoRole = it.key(); } QVERIFY(actionRole >= 0 && nameRole >= 0 && repoRole >= 0); auto actionFor = [&](const QString& name, const QString& repo) { const int n = model.rowCount(); for (int i = 0; i < n; ++i) { const QModelIndex idx = model.index(i); if (model.data(idx, nameRole).toString() != name) continue; if (model.data(idx, repoRole).toString() != repo) continue; return model.data(idx, actionRole).toString(); } return QString(); }; QCOMPARE(actionFor("wallet_ui", "repo2"), QStringLiteral("install")); QCOMPARE(actionFor("wallet_module", "repo2"), QStringLiteral("install")); // repo1 rows must stay clean — the dialog's filter pins to repo2, // so repo1's rows are off-screen; their action being empty is fine, // but they must NOT be carrying the overlay either. QCOMPARE(actionFor("wallet_ui", "repo1"), QString()); QCOMPARE(actionFor("wallet_module", "repo1"), QString()); } // ── Catalog icon: only resolvable URLs are accepted ───────────────── // The catalog's `icon` is historically a bare filename relative to the // installed package root ("modules.png"), which QML cannot load. Letting // it through would flip AppTile.hasIcon true, suppress the monogram, and // render an empty Image over the backplate — worse than no icon. Only // values that are already URLs may reach the model. void catalogIconRejectsBareFilename() { AppsModel model; QVariantMap row = makeCatalogRow("repo1", "wallet_ui", "1.0", "H"); row.insert(QStringLiteral("icon"), QStringLiteral("modules.png")); model.replaceCatalog({row}); QCOMPARE(iconUrlOf(model, "wallet_ui", "repo1"), QString()); } void catalogIconAcceptsResolvedUrl() { AppsModel model; QVariantMap row = makeCatalogRow("repo1", "wallet_ui", "1.0", "H"); row.insert(QStringLiteral("icon"), QStringLiteral("file:///cache/logos/icons/abc123.png")); model.replaceCatalog({row}); QCOMPARE(iconUrlOf(model, "wallet_ui", "repo1"), QStringLiteral("file:///cache/logos/icons/abc123.png")); } // An installed row's resolved file:// icon comes from the binary on disk // via setIconUrl(). A later catalog refresh carrying no usable icon must // not wipe it. void catalogRefreshDoesNotClobberResolvedIcon() { AppsModel model; model.replaceCatalog({makeCatalogRow("repo1", "wallet_ui", "1.0", "H")}); model.markInstalled("wallet_ui", "1.0", "H"); model.setIconUrl("wallet_ui", "file:///installed/assets/icon.png"); // Refresh with a bare-filename icon, as today's downloader emits. QVariantMap row = makeCatalogRow("repo1", "wallet_ui", "1.0", "H"); row.insert(QStringLiteral("icon"), QStringLiteral("modules.png")); model.replaceCatalog({row}); QCOMPARE(iconUrlOf(model, "wallet_ui", "repo1"), QStringLiteral("file:///installed/assets/icon.png")); } void catalogRefreshClearsStaleIconWhenNotInstalled() { AppsModel model; QVariantMap withIcon = makeCatalogRow("repo1", "wallet_ui", "1.0", "H"); withIcon.insert(QStringLiteral("icon"), QStringLiteral("file:///cache/abc.png")); model.replaceCatalog({withIcon}); QCOMPARE(iconUrlOf(model, "wallet_ui", "repo1"), QStringLiteral("file:///cache/abc.png")); // Refresh with a bare filename — no longer a usable URL. QVariantMap bare = makeCatalogRow("repo1", "wallet_ui", "1.0", "H"); bare.insert(QStringLiteral("icon"), QStringLiteral("modules.png")); model.replaceCatalog({bare}); QCOMPARE(iconUrlOf(model, "wallet_ui", "repo1"), QString()); } // ── Full-bleed icon gate ──────────────────────────────────────────── // The bundler refuses to emit a 0.4.0 package whose icon is not a // validated 256x256 assets/icon.png, so the version is a guarantee the // artwork is safe to render edge-to-edge. UIPluginManager's sidebar path // calls this same static, so the rule exists once. void supportsFullBleedIcon_versionGate() { QVERIFY(AppsModel::supportsFullBleedIcon("0.4.0")); QVERIFY(AppsModel::supportsFullBleedIcon("0.4.1")); QVERIFY(AppsModel::supportsFullBleedIcon("1.0.0")); QVERIFY(!AppsModel::supportsFullBleedIcon("0.3.0")); QVERIFY(!AppsModel::supportsFullBleedIcon("0.2.9")); // Unparseable fails CLOSED — an unknown manifest has promised // nothing, so it gets the safe inset rendering. QVERIFY(!AppsModel::supportsFullBleedIcon("")); QVERIFY(!AppsModel::supportsFullBleedIcon("0.bad")); QVERIFY(!AppsModel::supportsFullBleedIcon("garbage")); } void catalogRowCarriesFullBleedFlag() { AppsModel model; QVariantMap legacy = makeCatalogRow("repo1", "legacy_ui", "1.0", "H1"); legacy.insert(QStringLiteral("manifestVersion"), QStringLiteral("0.3.0")); QVariantMap modern = makeCatalogRow("repo1", "modern_ui", "1.0", "H2"); modern.insert(QStringLiteral("manifestVersion"), QStringLiteral("0.4.0")); model.replaceCatalog({legacy, modern}); int flagRole = -1, nameRole = -1; const auto& roles = model.roleNames(); for (auto it = roles.cbegin(); it != roles.cend(); ++it) { if (it.value() == "supportsFullBleedIcon") flagRole = it.key(); else if (it.value() == "name") nameRole = it.key(); } QVERIFY(flagRole >= 0 && nameRole >= 0); auto flagFor = [&](const QString& name) { for (int i = 0; i < model.rowCount(); ++i) { const QModelIndex idx = model.index(i); if (model.data(idx, nameRole).toString() == name) return model.data(idx, flagRole).toBool(); } return false; }; QCOMPARE(flagFor("legacy_ui"), false); QCOMPARE(flagFor("modern_ui"), true); } // A row with no manifestVersion (older producer, or a repo predating the // field) must not claim full-bleed support — inset is the safe default. void missingManifestVersionDefaultsToInset() { AppsModel model; model.replaceCatalog({makeCatalogRow("repo1", "wallet_ui", "1.0", "H")}); int flagRole = -1; const auto& roles = model.roleNames(); for (auto it = roles.cbegin(); it != roles.cend(); ++it) if (it.value() == "supportsFullBleedIcon") flagRole = it.key(); QVERIFY(flagRole >= 0); QCOMPARE(model.data(model.index(0), flagRole).toBool(), false); } // ── Regression: multi-repo setInstallType / setIconUrl ───────────── // installType and iconUrl come from the installed binary, not the // catalog row. Every (repo, name) tile must show them; previously // the setters used rowOf(name) and only updated the first match. void installTypeAndIconUrlPropagateAcrossRepos() { AppsModel model; model.replaceCatalog({ makeCatalogRow("repo1", "wallet_ui", "1.0", "H"), makeCatalogRow("repo2", "wallet_ui", "1.0", "H"), }); model.setInstallType("wallet_ui", "user"); model.setIconUrl("wallet_ui", "file:///path/to/icon.png"); int typeRole = -1, iconRole = -1, nameRole = -1, repoRole = -1; const auto& roles = model.roleNames(); for (auto it = roles.cbegin(); it != roles.cend(); ++it) { if (it.value() == "installType") typeRole = it.key(); else if (it.value() == "iconUrl") iconRole = it.key(); else if (it.value() == "name") nameRole = it.key(); else if (it.value() == "repositoryUrl") repoRole = it.key(); } QVERIFY(typeRole >= 0 && iconRole >= 0 && nameRole >= 0 && repoRole >= 0); auto fieldFor = [&](int role, const QString& name, const QString& repo) { const int n = model.rowCount(); for (int i = 0; i < n; ++i) { const QModelIndex idx = model.index(i); if (model.data(idx, nameRole).toString() != name) continue; if (model.data(idx, repoRole).toString() != repo) continue; return model.data(idx, role).toString(); } return QString(); }; QCOMPARE(fieldFor(typeRole, "wallet_ui", "repo1"), QStringLiteral("user")); QCOMPARE(fieldFor(typeRole, "wallet_ui", "repo2"), QStringLiteral("user")); QCOMPARE(fieldFor(iconRole, "wallet_ui", "repo1"), QStringLiteral("file:///path/to/icon.png")); QCOMPARE(fieldFor(iconRole, "wallet_ui", "repo2"), QStringLiteral("file:///path/to/icon.png")); } // ── Regression: per-row install stage isolation on partial failure ─ // When the install loop reports row A success then row B failure, // each row's installStage must reflect ONLY its own outcome — no // cross-row contamination, even though they belong to the same // top-level session. void installStageIsolatedAcrossRowsOnPartialFailure() { AppsModel model; InstallRegistry reg; model.setInstallRegistry(®); model.replaceCatalog({ makeCatalogRow("repo1", "module_a", "1.0", "H_a"), makeCatalogRow("repo1", "module_b", "1.0", "H_b"), }); // Simulate installResultsSequential's per-row stage transitions. stage(reg, "module_a", InstallStage::Installing); stage(reg, "module_a", InstallStage::Installed); stage(reg, "module_b", InstallStage::Installing); stageFailed(reg, "module_b", "package_manager returned no path"); int stageRole = -1, errorRole = -1, nameRole = -1; const auto& roles = model.roleNames(); for (auto it = roles.cbegin(); it != roles.cend(); ++it) { if (it.value() == "installStage") stageRole = it.key(); else if (it.value() == "installError") errorRole = it.key(); else if (it.value() == "name") nameRole = it.key(); } QVERIFY(stageRole >= 0 && errorRole >= 0 && nameRole >= 0); auto rowFor = [&](const QString& name) { const int n = model.rowCount(); for (int i = 0; i < n; ++i) { const QModelIndex idx = model.index(i); if (model.data(idx, nameRole).toString() != name) continue; return idx; } return QModelIndex(); }; QCOMPARE(static_cast( model.data(rowFor("module_a"), stageRole).toInt()), InstallStage::Installed); QCOMPARE(model.data(rowFor("module_a"), errorRole).toString(), QString()); QCOMPARE(static_cast( model.data(rowFor("module_b"), stageRole).toInt()), InstallStage::Failed); QCOMPARE(model.data(rowFor("module_b"), errorRole).toString(), QStringLiteral("package_manager returned no path")); } // ── Regression: coordinator clears sticky installStage on resolver // overlay apply ──────────────────────────────────────────────────── // // Without this, a row left at InstallStage::Installed from a previous // install session keeps reading "Installed" in PackageRowDelegate's // stage switch on the next dialog open, masking a fresh action like // Upgrade or Reinstall. void resolverOverlayClearsStickyInstallStage() { AppsModel model; InstallRegistry reg; model.setInstallRegistry(®); model.replaceCatalog({ makeCatalogRow("repo1", "wallet_module", "1.0", "H_mod"), }); stage(reg, "wallet_module", InstallStage::Installed); int stageRole = -1, nameRole = -1, repoRole = -1; const auto& roles = model.roleNames(); for (auto it = roles.cbegin(); it != roles.cend(); ++it) { if (it.value() == "installStage") stageRole = it.key(); else if (it.value() == "name") nameRole = it.key(); else if (it.value() == "repositoryUrl") repoRole = it.key(); } QVERIFY(stageRole >= 0 && nameRole >= 0 && repoRole >= 0); auto stageFor = [&](const QString& name, const QString& repo) { const int n = model.rowCount(); for (int i = 0; i < n; ++i) { const QModelIndex idx = model.index(i); if (model.data(idx, nameRole).toString() != name) continue; if (model.data(idx, repoRole).toString() != repo) continue; return static_cast( model.data(idx, stageRole).toInt()); } return InstallStage::None; }; QCOMPARE(stageFor("wallet_module", "repo1"), InstallStage::Installed); AppsModel::ResolverRow rr; rr.name = "wallet_module"; rr.repositoryUrl = "repo1"; rr.action = "reinstall"; rr.toVersion = "1.0"; model.setResolverOverlay({rr}); reg.clear("wallet_module"); QCOMPARE(stageFor("wallet_module", "repo1"), InstallStage::None); } // ── Regression: dep-walk needs the DEP'S installedHash populated. // Reproduces the runtime bug where wallet_module (a core module, not a // UI plugin) never had its installedHash set on the AppsModel row, so // wallet_ui's dep walk skipped the per-dep DifferentHash check. The // fix in populateAppsModel iterates the full installed set, not just // the UI-plugin subset. This test stays at the model layer by simply // omitting the markInstalled call for the dep — same observable // effect. void dependentTileStaysInstalledWhenDepNotMarked() { AppsModel model; model.replaceCatalog({ makeCatalogRow("repo1", "wallet_ui", "1.0.0", "H_ui_same", { makeDep("wallet_module") }), makeCatalogRow("repo1", "wallet_module", "1.0.1", "H_mod_a"), makeCatalogRow("repo2", "wallet_ui", "1.0.0", "H_ui_same", { makeDep("wallet_module") }), makeCatalogRow("repo2", "wallet_module", "1.0.1", "H_mod_b"), }); // Mark wallet_ui installed but NOT wallet_module — the prod bug // shape (PackageCoordinator's UI-only replay loop). model.markInstalled("wallet_ui", "1.0.0", "H_ui_same"); // With dep installedHash missing, both wallet_ui rows incorrectly // resolve to Installed — captures the buggy behaviour. QCOMPARE(statusOf(model, "wallet_ui", "repo1"), InstallStatus::Installed); QCOMPARE(statusOf(model, "wallet_ui", "repo2"), InstallStatus::Installed); // After the full replay (now done by populateAppsModel), the dep // walk has data and repo2 correctly demotes to DifferentHash. model.markInstalled("wallet_module", "1.0.1", "H_mod_a"); QCOMPARE(statusOf(model, "wallet_ui", "repo1"), InstallStatus::Installed); QCOMPARE(statusOf(model, "wallet_ui", "repo2"), InstallStatus::DifferentHash); } // ── Mirrors EXACT runtime data from the user's restart-loses-Reinstall // report: both repos publish wallet_ui at the SAME hash, but // wallet_module differs. Tile state must be Installed on both wallet_ui // rows AND DifferentHash on dlipicar's wallet_module row AND // DifferentHash on dlipicar's wallet_ui row (via dep walk against the // mismatched wallet_module). void userRuntimeData_walletMultiRepoDepWalk() { AppsModel model; model.replaceCatalog({ makeCatalogRow("logos-co", "wallet_ui", "1.0.0", "831f345bd9c9...", { makeDep("wallet_module") }), makeCatalogRow("logos-co", "wallet_module", "1.0.1", "73a30234d0f7..."), makeCatalogRow("dlipicar", "wallet_ui", "1.0.0", "831f345bd9c9...", // SAME as logos-co { makeDep("wallet_module") }), makeCatalogRow("dlipicar", "wallet_module", "1.0.1", "cf2f3db583f3..."), // DIFFERENT from logos-co }); // Installed binaries (per user's runtime log). model.markInstalled("wallet_ui", "1.0.0", "831f345bd9c9..."); model.markInstalled("wallet_module", "1.0.1", "73a30234d0f7..."); // logos-co matches on-disk binary for both → Installed. QCOMPARE(statusOf(model, "wallet_ui", "logos-co"), InstallStatus::Installed); QCOMPARE(statusOf(model, "wallet_module", "logos-co"), InstallStatus::Installed); // dlipicar's wallet_module differs from on-disk → DifferentHash. QCOMPARE(statusOf(model, "wallet_module", "dlipicar"), InstallStatus::DifferentHash); // dlipicar's wallet_ui: same top-level hash as on-disk, BUT its // declared dep wallet_module differs against dlipicar's catalog → // dep walk must demote it to DifferentHash. QCOMPARE(statusOf(model, "wallet_ui", "dlipicar"), InstallStatus::DifferentHash); } // ── Idempotence + role notification contracts ────────────────────── // These lock the "calling a setter with the same value is a silent // no-op" contract that QML bindings depend on — a redundant // dataChanged emit can re-trigger expensive QML re-renders, and a // missing emit hides real changes from the view. void markInstalled_idempotent_no_op_when_unchanged() { AppsModel model; model.replaceCatalog({ makeCatalogRow("repo1", "wallet_ui", "1.0", "H"), }); model.markInstalled("wallet_ui", "1.0", "H"); // Spy on dataChanged. A second mark with identical (version, hash) // must not emit — the row already holds those values. QSignalSpy spy(&model, &QAbstractItemModel::dataChanged); model.markInstalled("wallet_ui", "1.0", "H"); QCOMPARE(spy.count(), 0); } void replaceCatalog_removes_rows_not_in_incoming() { AppsModel model; model.replaceCatalog({ makeCatalogRow("repo1", "wallet_ui", "1.0", "H1"), makeCatalogRow("repo1", "wallet_module", "1.0", "H2"), }); QCOMPARE(model.rowCount(), 2); // wallet_module disappears from the catalog. model.replaceCatalog({ makeCatalogRow("repo1", "wallet_ui", "1.0", "H1"), }); QCOMPARE(model.rowCount(), 1); } void replaceCatalog_preserves_installState_on_existing_row_update() { AppsModel model; model.replaceCatalog({ makeCatalogRow("repo1", "wallet_ui", "1.0", "H"), }); model.markInstalled("wallet_ui", "1.0", "H"); QCOMPARE(statusOf(model, "wallet_ui", "repo1"), InstallStatus::Installed); // Re-issue the catalog — same shape, same row identity. The // installed state must survive the refresh; we shouldn't see a // tile blink to NotInstalled mid-refresh. model.replaceCatalog({ makeCatalogRow("repo1", "wallet_ui", "1.0", "H"), }); QCOMPARE(statusOf(model, "wallet_ui", "repo1"), InstallStatus::Installed); } void setMissingDeps_unknownName_is_silent_noop() { AppsModel model; model.replaceCatalog({ makeCatalogRow("repo1", "wallet_ui", "1.0", "H"), }); QSignalSpy spy(&model, &QAbstractItemModel::dataChanged); // No row with this name — must not crash, must not emit. model.setMissingDeps("does_not_exist", { "foo" }); QCOMPARE(spy.count(), 0); } void clearResolverOverlay_resets_action_and_isTopLevel() { AppsModel model; model.replaceCatalog({ makeCatalogRow("repo1", "wallet_ui", "1.0", "H"), }); AppsModel::ResolverRow rr; rr.name = "wallet_ui"; rr.repositoryUrl = "repo1"; rr.action = "install"; rr.toVersion = "1.0"; rr.isTopLevel = true; model.setResolverOverlay({rr}); int actionRole = -1, isTopRole = -1, nameRole = -1; const auto& roles = model.roleNames(); for (auto it = roles.cbegin(); it != roles.cend(); ++it) { if (it.value() == "action") actionRole = it.key(); else if (it.value() == "isTopLevel") isTopRole = it.key(); else if (it.value() == "name") nameRole = it.key(); } const QModelIndex idx = [&]{ for (int i = 0; i < model.rowCount(); ++i) { const QModelIndex mi = model.index(i); if (model.data(mi, nameRole).toString() == "wallet_ui") return mi; } return QModelIndex{}; }(); QVERIFY(idx.isValid()); QCOMPARE(model.data(idx, actionRole).toString(), QStringLiteral("install")); QCOMPARE(model.data(idx, isTopRole).toBool(), true); model.clearResolverOverlay(); QCOMPARE(model.data(idx, actionRole).toString(), QString()); QCOMPARE(model.data(idx, isTopRole).toBool(), false); } void recomputeInstallStatus_emptyCatalogVersion_falls_back_to_Installed() { // Catalog row exists but has no usable version data (older publishers, // partial entries). Once installed, recomputeInstallStatus has nothing // to compare against — best-effort Installed, NOT NotInstalled. AppsModel model; QVariantMap row; row.insert("name", "ghost_app"); row.insert("repositoryUrl", "repo1"); row.insert("versions", QVariantList{}); // empty model.replaceCatalog({ row }); model.markInstalled("ghost_app", "1.0", "H"); QCOMPARE(statusOf(model, "ghost_app", "repo1"), InstallStatus::Installed); } void emptyCatalog_zero_rows_no_crash() { AppsModel model; InstallRegistry reg; model.setInstallRegistry(®); model.replaceCatalog({}); QCOMPARE(model.rowCount(), 0); // Mutators against empty model are no-ops — no row for AppsModel to // mutate, and stage() through the registry has no matching row so // the InstallStageRole read still yields None. model.markInstalled("anything", "1.0", "H"); model.setMissingDeps("anything", { "x" }); stage(reg, "anything", InstallStage::Installed); QCOMPARE(model.rowCount(), 0); } // ── Role-name contract ──────────────────────────────────────────── // QML reads roles by NAME (e.g. `model.installStatus`, `appRow.action`), // so renaming any of these silently breaks every binding. Locks the // exact string each role serialises to. void roleNames_stable() { AppsModel model; const auto roles = model.roleNames(); QHash seen; for (auto it = roles.cbegin(); it != roles.cend(); ++it) seen.insert(it.value(), true); const QList required{ "name", "repositoryUrl", "displayName", "description", "category", "type", "iconUrl", "supportsFullBleedIcon", "versions", "dependencies", "installedVersion", "latestVersion", "hasUpdate", "isInstalled", "missingDeps", "installStatus", "installType", "action", "toVersion", "isTopLevel", "resolverError", "installStage", "installError", }; for (const QByteArray& r : required) QVERIFY2(seen.contains(r), qPrintable("missing role: " + r)); } // ── Schema: every produced row's dependencies field is a normalised // list of {name, version} maps regardless of input shape (string vs // object). The dialog filter + dep walk both read `dep.name`; a row // dropped silently here cascades into every multi-repo Reinstall // detection downstream. void dependencies_normalise_string_and_object_inputs() { AppsModel model; QVariantMap manifestStr; manifestStr.insert("version", "1.0"); manifestStr.insert("dependencies", QVariantList{ QVariant("wallet_module") }); QVariantMap manifestObj; manifestObj.insert("version", "1.0"); manifestObj.insert("dependencies", QVariantList{ QVariantMap{ {"name", "wallet_module"}, {"version", ">=1.0"} }, }); QVariantMap rowStr; rowStr.insert("name", "uses_string_dep"); rowStr.insert("repositoryUrl", "r"); rowStr.insert("versions", QVariantList{ QVariantMap{ {"rootHash", "H"}, {"manifest", manifestStr} }}); QVariantMap rowObj; rowObj.insert("name", "uses_object_dep"); rowObj.insert("repositoryUrl", "r"); rowObj.insert("versions", QVariantList{ QVariantMap{ {"rootHash", "H"}, {"manifest", manifestObj} }}); model.replaceCatalog({ rowStr, rowObj }); int depsRole = -1, nameRole = -1; const auto& roles = model.roleNames(); for (auto it = roles.cbegin(); it != roles.cend(); ++it) { if (it.value() == "dependencies") depsRole = it.key(); else if (it.value() == "name") nameRole = it.key(); } QVERIFY(depsRole >= 0 && nameRole >= 0); for (int i = 0; i < model.rowCount(); ++i) { const QModelIndex idx = model.index(i); const QVariantList deps = model.data(idx, depsRole).toList(); QCOMPARE(deps.size(), 1); QCOMPARE(deps.first().toMap().value("name").toString(), QStringLiteral("wallet_module")); } } // ── mergeLocalOnlyInstalled + replaceCatalog local-preservation ──── // Locks the "installed-but-uncatalogued" behaviour: names in the // installed-set that the catalog doesn't publish get a synthetic row // with empty repositoryUrl (rendered under a "Local" section). Names // the catalog does publish are the catalog's job — mergeLocalOnly // must not double-insert. void mergeLocalOnly_adds_row_for_uncatalogued_installed() { AppsModel model; model.replaceCatalog({}); // no repos configured model.mergeLocalOnlyInstalled({ makeInstalledPackage("side_loaded_module", "1.2.3", "H_side", "utilities"), }); QCOMPARE(model.rowCount(), 1); int nameRole = -1, repoRole = -1, catRole = -1, verRole = -1, typeRole = -1, statusRole = -1; const auto& roles = model.roleNames(); for (auto it = roles.cbegin(); it != roles.cend(); ++it) { if (it.value() == "name") nameRole = it.key(); else if (it.value() == "repositoryUrl") repoRole = it.key(); else if (it.value() == "category") catRole = it.key(); else if (it.value() == "installedVersion") verRole = it.key(); else if (it.value() == "installType") typeRole = it.key(); else if (it.value() == "installStatus") statusRole = it.key(); } const QModelIndex idx = model.index(0); QCOMPARE(model.data(idx, nameRole).toString(), QStringLiteral("side_loaded_module")); // Repo empty = the "Local" bucket marker. QCOMPARE(model.data(idx, repoRole).toString(), QString()); // Module's own category survives — Local is a repo slot, not a // category override. QCOMPARE(model.data(idx, catRole).toString(), QStringLiteral("utilities")); QCOMPARE(model.data(idx, verRole).toString(), QStringLiteral("1.2.3")); QCOMPARE(model.data(idx, typeRole).toString(), QStringLiteral("user")); // No catalog version to compare against → Installed best-effort. QCOMPARE(static_cast(model.data(idx, statusRole).toInt()), InstallStatus::Installed); } void mergeLocalOnly_skips_names_covered_by_catalog() { AppsModel model; model.replaceCatalog({ makeCatalogRow("repo1", "wallet_ui", "1.0", "H_repo"), }); model.mergeLocalOnlyInstalled({ // Catalog already has wallet_ui → NO synthetic row. makeInstalledPackage("wallet_ui", "1.0", "H_repo", "wallet"), // No catalog row for orphan_mod → gets a Local row. makeInstalledPackage("orphan_mod", "1.0", "H_orphan", "misc"), }); // 1 catalog row + 1 local row (wallet_ui not duplicated). QCOMPARE(model.rowCount(), 2); int nameRole = -1, repoRole = -1; const auto& roles = model.roleNames(); for (auto it = roles.cbegin(); it != roles.cend(); ++it) { if (it.value() == "name") nameRole = it.key(); else if (it.value() == "repositoryUrl") repoRole = it.key(); } // wallet_ui stays on its catalog row (repo1). orphan_mod is Local. bool sawWalletOnRepo = false, sawOrphanOnLocal = false; for (int i = 0; i < model.rowCount(); ++i) { const QModelIndex idx = model.index(i); const QString n = model.data(idx, nameRole).toString(); const QString r = model.data(idx, repoRole).toString(); if (n == "wallet_ui" && r == "repo1") sawWalletOnRepo = true; if (n == "orphan_mod" && r == "") sawOrphanOnLocal = true; } QVERIFY(sawWalletOnRepo); QVERIFY(sawOrphanOnLocal); } void mergeLocalOnly_is_idempotent() { AppsModel model; model.mergeLocalOnlyInstalled({ makeInstalledPackage("orphan_mod", "1.0", "H", "misc"), }); QCOMPARE(model.rowCount(), 1); // Second call with the same input must not duplicate. model.mergeLocalOnlyInstalled({ makeInstalledPackage("orphan_mod", "1.0", "H", "misc"), }); QCOMPARE(model.rowCount(), 1); } // ── Regression: uninstalling a local-only module removes its row ──── // // Bug: mergeLocalOnlyInstalled is called by PackageCoordinator after // every install/uninstall/reload with the FRESH installed-packages // list. When a user uninstalls a local-only module via PMUI: // - PMUI removes it from its own state (row disappears from PMUI). // - PackageCoordinator's uiPluginUninstalled handler fires refresh. // - refreshDependencyInfo repopulates m_installedPackagesCache with // the fresh list (module gone). // - AppsModel::mergeLocalOnlyInstalled(fresh_cache) runs. // The row must vanish. Before the fix, mergeLocalOnlyInstalled was // add-only — the previously-inserted local row survived because the // merge iterates the fresh list and only adds; there was no removal // pass for names that dropped out. Reload runs the same code path so // nothing recovered until an app restart. void mergeLocalOnly_removes_row_when_installed_set_drops_the_name() { AppsModel model; model.replaceCatalog({}); model.mergeLocalOnlyInstalled({ makeInstalledPackage("orphan_mod", "1.0", "H", "misc"), }); QCOMPARE(model.rowCount(), 1); // User uninstalls orphan_mod → next merge sees an empty user set. // The Local row for it must disappear. model.mergeLocalOnlyInstalled({}); QCOMPARE(model.rowCount(), 0); } // Same reconciliation with a catalog present: catalog rows are the // catalog's job (replaceCatalog owns them), so they must NOT be // affected by the local-only prune. Only rows with empty // repositoryUrl whose name is no longer in the fresh installed set // are dropped. void mergeLocalOnly_removes_only_local_rows_leaves_catalog_intact() { AppsModel model; model.replaceCatalog({ makeCatalogRow("repo1", "wallet_ui", "1.0", "H_ui"), }); model.mergeLocalOnlyInstalled({ makeInstalledPackage("orphan_mod", "1.0", "H_orphan", "misc"), }); QCOMPARE(model.rowCount(), 2); // catalog + local // Uninstall orphan_mod. wallet_ui is not user-installed either // (empty fresh set) — its catalog row must still survive because // catalog rows are managed by replaceCatalog, not by the local // merge. model.mergeLocalOnlyInstalled({}); QCOMPARE(model.rowCount(), 1); int nameRole = -1, repoRole = -1; const auto& roles = model.roleNames(); for (auto it = roles.cbegin(); it != roles.cend(); ++it) { if (it.value() == "name") nameRole = it.key(); else if (it.value() == "repositoryUrl") repoRole = it.key(); } const QModelIndex idx = model.index(0); QCOMPARE(model.data(idx, nameRole).toString(), QStringLiteral("wallet_ui")); QCOMPARE(model.data(idx, repoRole).toString(), QStringLiteral("repo1")); } // Embedded packages ship inside the app bundle, not under Application // Support. They're already surfaced through the built-in module list — // synthesising a Local row for them would double-list. Gate: only // installType == "user" packages become Local rows. void mergeLocalOnly_skips_embedded_installType() { AppsModel model; model.mergeLocalOnlyInstalled({ makeInstalledPackage("embedded_mod", "1.0", "H_emb", "misc", QStringLiteral("embedded")), makeInstalledPackage("user_mod", "2.0", "H_usr", "misc", QStringLiteral("user")), }); QCOMPARE(model.rowCount(), 1); int nameRole = -1; const auto& roles = model.roleNames(); for (auto it = roles.cbegin(); it != roles.cend(); ++it) { if (it.value() == "name") { nameRole = it.key(); break; } } QCOMPARE(model.data(model.index(0), nameRole).toString(), QStringLiteral("user_mod")); } void replaceCatalog_preserves_local_row_when_catalog_still_lacks_it() { AppsModel model; model.replaceCatalog({}); model.mergeLocalOnlyInstalled({ makeInstalledPackage("orphan_mod", "1.0", "H", "misc"), }); QCOMPARE(model.rowCount(), 1); // A subsequent catalog refresh — different repos come and go — must // not sweep away the local row that no repo has adopted yet. model.replaceCatalog({ makeCatalogRow("repo1", "wallet_ui", "1.0", "H_ui"), }); QCOMPARE(model.rowCount(), 2); // catalog row + local row int nameRole = -1, repoRole = -1; const auto& roles = model.roleNames(); for (auto it = roles.cbegin(); it != roles.cend(); ++it) { if (it.value() == "name") nameRole = it.key(); else if (it.value() == "repositoryUrl") repoRole = it.key(); } bool sawOrphanLocal = false; for (int i = 0; i < model.rowCount(); ++i) { const QModelIndex idx = model.index(i); if (model.data(idx, nameRole).toString() == "orphan_mod" && model.data(idx, repoRole).toString().isEmpty()) { sawOrphanLocal = true; break; } } QVERIFY(sawOrphanLocal); } void replaceCatalog_drops_local_row_when_catalog_adopts_the_name() { AppsModel model; model.replaceCatalog({}); model.mergeLocalOnlyInstalled({ makeInstalledPackage("wallet_ui", "1.0", "H_installed", "wallet"), }); QCOMPARE(model.rowCount(), 1); // A repo is added and now publishes wallet_ui — the local row must // go away, replaced by the catalog row. model.replaceCatalog({ makeCatalogRow("repo1", "wallet_ui", "1.0", "H_installed"), }); QCOMPARE(model.rowCount(), 1); int nameRole = -1, repoRole = -1; const auto& roles = model.roleNames(); for (auto it = roles.cbegin(); it != roles.cend(); ++it) { if (it.value() == "name") nameRole = it.key(); else if (it.value() == "repositoryUrl") repoRole = it.key(); } const QModelIndex idx = model.index(0); QCOMPARE(model.data(idx, nameRole).toString(), QStringLiteral("wallet_ui")); // Now under repo1, not the Local bucket. QCOMPARE(model.data(idx, repoRole).toString(), QStringLiteral("repo1")); } // The `color` role is deliberately gone. It was read by AppsModel and // rendered by AppTile, but no producer anywhere in the stack ever emitted // it — not the LGX manifest, not the downloader's catalog synthesis. With // full-bleed icons on a fixed theme-grey plate there is no author- // controlled tile colour to carry, so the role was removed rather than // left as a permanently-empty field. The hash-derived monogram colour // (AppColors.colorForApp) is computed in QML and needs no model role. void replaceCatalog_hasNoColorRole() { AppsModel model; model.replaceCatalog({makeCatalogRow("repo1", "storage_ui", "1.0", "H")}); const auto& roles = model.roleNames(); for (auto it = roles.cbegin(); it != roles.cend(); ++it) QVERIFY2(it.value() != "color", "AppsModel must not expose a `color` role"); } }; QTEST_GUILESS_MAIN(AppsModelTest) #include "apps_model_test.moc"