mudlet/test/functional_tests/DefaultPackagesTest.cpp
Vadim Peretokin ddb1620e79
fix: brand-new installs get the new-player experience again (#9745)
#### Brief overview of PR changes/additions
- Stop importing settings from the pre-4.19 NativeFormat store (macOS
plists / Windows registry) into a freshly created Mudlet.ini
- On those platforms the old store ignores HOME/XDG overrides, so any
machine that ever ran Mudlet <= 4.18 made every fresh install inherit
stale keys and classify as an experienced player - skipping the starter
UI, tour and hints, and failing two functional tests
- Add tripwire asserts so any future write into a fresh Mudlet.ini
before init() fails with a self-explaining message

#### Motivation for adding to Mudlet
A fresh install must be recognised as a new player; anyone who ran 4.19+
was already migrated, and a direct <= 4.18 upgrade only loses UI
preferences (window geometry, appearance, storePasswordsSecurely,
deletedDefaultMuds), never profiles.

#### Other info (issues closed, discussion etc)
Fixes #9741

**Test case:** ExperiencedPlayerGateTest (18/18) and DefaultPackagesTest
(28/28) pass; on a Mac that ran Mudlet <= 4.18 (check with `defaults
read com.mudlet.Mudlet pos`), both suites now pass and a fresh profile
gets the starter UI.

Assisted-by: Claude:claude-opus-5
2026-08-12 11:14:58 +02:00

235 lines
11 KiB
C++

/***************************************************************************
* Copyright (C) 2026 by Mudlet Developers *
* *
* This program is free software; you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation; either version 2 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program; if not, write to the *
* Free Software Foundation, Inc., *
* 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. *
***************************************************************************/
/*
* Covers the packages Mudlet preinstalls into new profiles, which live in
* src/packages and are compiled in through mudlet.qrc.
*
* Nothing else notices when one of these breaks: a path in
* setupPreInstallPackages() that no longer names a compiled-in resource, or an
* archive rebuilt without config.lua, just means the profile quietly comes up
* without the package. The build stays green either way, so this test walks
* both the preinstall table and every archive in the resource tree.
*
* Run with: ctest -R DefaultPackagesTest -V
*/
#include <QtTest/QtTest>
#include <QTemporaryDir>
#include <QTemporaryFile>
#include "Host.h"
#include "HostManager.h"
#include "MudletInstanceCoordinator.h"
#include "mudlet.h"
extern void qInitResources_mudlet();
extern void qInitResources_qm();
extern void qInitResources_additional_splash_screens();
extern void qInitResources_mudlet_fonts_common();
extern void qInitResources_mudlet_fonts_posix();
void initializeQRCResourcesForDefaultPackagesTest();
class DefaultPackagesTest : public QObject
{
Q_OBJECT
private:
// A package installs under the name its config.lua declares, which is
// normally the directory it lives in. These two are deliberately not: the
// tutorial uses a display name, and the Carrion Fields loader has to match
// the name its own script passes to uninstallPackage() when it is done.
inline static const QHash<QString, QString> scmInstallsAs = {{qsl("mudlet-tutorial"), qsl("Mudlet Tutorial")}, {qsl("CF-loader"), qsl("CF_Loader")}};
const QString mProfileName = qsl("DefaultPackages-Test");
QTemporaryDir mConfigDir;
QByteArray mSavedXdg;
Host* mpHost = nullptr;
QStringList preinstallsFor(const QString& gameUrl, const QString& profileName = qsl("test"))
{
mudlet::self()->mPackagesToInstallList.clear();
mudlet::self()->setupPreInstallPackages(gameUrl, profileName);
return mudlet::self()->mPackagesToInstallList;
}
private slots:
void initTestCase()
{
initializeQRCResourcesForDefaultPackagesTest();
// Keep the test hermetic: point the config dir resolution at a
// temporary directory instead of the user's real profiles.
QVERIFY(mConfigDir.isValid());
mSavedXdg = qgetenv("XDG_CONFIG_HOME");
QVERIFY(QDir().mkpath(qsl("%1/mudlet/profiles").arg(mConfigDir.path())));
qputenv("XDG_CONFIG_HOME", mConfigDir.path().toUtf8());
mudlet::start();
mudlet::self()->setupConfig();
QCOMPARE(mudlet::getMudletPath(enums::mainPath), qsl("%1/mudlet").arg(mConfigDir.path()));
QVERIFY2(mudlet::getQSettings()->allKeys().isEmpty(), "a fresh config dir must start out with an empty Mudlet.ini - something wrote settings before init()");
mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator"));
mudlet::self()->init();
mudlet::self()->setStorePasswordsSecurely(false);
QVERIFY2(mudlet::self()->getHostManager().addHost(mProfileName, QString(), QString(), QString()), "failed to create the Host");
mpHost = mudlet::self()->getHostManager().getHost(mProfileName);
QVERIFY(mpHost);
}
void cleanupTestCase()
{
mpHost = nullptr;
delete mudlet::self();
mSavedXdg.isNull() ? qunsetenv("XDG_CONFIG_HOME") : qputenv("XDG_CONFIG_HOME", mSavedXdg);
}
// Every path the preinstall table hands out has to name something that was
// actually compiled into the binary, or the profile silently misses it.
void test_preinstalledPathsResolve_data()
{
QTest::addColumn<QString>("gameUrl");
QTest::newRow("any game") << qsl("example.com");
QTest::newRow("carrion fields") << qsl("carrionfields.net");
QTest::newRow("icesus") << qsl("icesus.org");
QTest::newRow("morgengrauen") << qsl("mg.mud.de");
QTest::newRow("medievia") << qsl("medievia.com");
QTest::newRow("an IRE game") << qsl("achaea.com");
QTest::newRow("mudlet's own") << qsl("mudlet.org");
}
void test_preinstalledPathsResolve()
{
QFETCH(QString, gameUrl);
const QStringList paths = preinstallsFor(gameUrl);
QVERIFY2(!paths.isEmpty(), qPrintable(qsl("no packages queued for %1").arg(gameUrl)));
for (const QString& path : paths) {
QVERIFY2(path.startsWith(qsl(":/")), qPrintable(qsl("%1 is not a resource path").arg(path)));
QVERIFY2(QFile::exists(path), qPrintable(qsl("%1 is queued for %2 but is not compiled in").arg(path, gameUrl)));
}
}
void test_tutorialProfileGetsTheTutorial()
{
QVERIFY(preinstallsFor(qsl("localhost"), qsl("Mudlet Tutorial")).contains(qsl(":/packages/mudlet-tutorial/mudlet-tutorial.mpackage")));
QVERIFY(!preinstallsFor(qsl("localhost"), qsl("some other profile")).contains(qsl(":/packages/mudlet-tutorial/mudlet-tutorial.mpackage")));
}
// Games that install an interface of their own get a loader instead of the
// starter UI, which would otherwise fight it for the same screen space.
// This config dir has no profiles, so the player counts as new to Mudlet.
void test_gamesWithTheirOwnUiSkipTheStarterUi()
{
QVERIFY(preinstallsFor(qsl("example.com")).contains(qsl(":/packages/mudlet-base-ui/mudlet-base-ui.mpackage")));
QVERIFY(!preinstallsFor(qsl("mg.mud.de")).contains(qsl(":/packages/mudlet-base-ui/mudlet-base-ui.mpackage")));
QVERIFY(preinstallsFor(qsl("mg.mud.de")).contains(qsl(":/packages/mg-loader/mg-loader.mpackage")));
}
// The generic mapper is for games that have no mapper script of their own.
void test_ireGamesGetTheirOwnMapper()
{
QVERIFY(preinstallsFor(qsl("achaea.com")).contains(qsl(":/mudlet-mapper.xml")));
QVERIFY(!preinstallsFor(qsl("achaea.com")).contains(qsl(":/packages/generic_mapper/generic_mapper.mpackage")));
QVERIFY(preinstallsFor(qsl("example.com")).contains(qsl(":/packages/generic_mapper/generic_mapper.mpackage")));
}
// Installing is what the preinstall table ultimately does, and a package
// that unpacks but does not import leaves the profile just as empty.
void test_packagesInstall_data()
{
QTest::addColumn<QString>("package");
for (const QString& package : QDir(qsl(":/packages")).entryList(QDir::Dirs | QDir::NoDotAndDotDot)) {
QTest::newRow(qPrintable(package)) << package;
}
}
void test_packagesInstall()
{
QFETCH(QString, package);
mpHost->mBlockScriptCompile = false;
auto [installed, message] = mpHost->installPackage(qsl(":/packages/%1/%1.mpackage").arg(package), enums::PackageModuleType::Package, true);
QVERIFY2(installed, qPrintable(qsl("%1 failed to install: %2").arg(package, message)));
const QString installedAs = scmInstallsAs.value(package, package);
QVERIFY2(mpHost->mInstalledPackages.contains(installedAs), qPrintable(qsl("%1 installed but is not registered as %2").arg(package, installedAs)));
}
// Each package directory carries the archive Mudlet installs. Unpack every
// one the way Host::installPackage() does and check it is shaped the way
// the installer requires: metadata in config.lua, exactly one xml.
void test_everyArchiveIsWellFormed()
{
const QStringList packages = QDir(qsl(":/packages")).entryList(QDir::Dirs | QDir::NoDotAndDotDot);
QVERIFY2(!packages.isEmpty(), "no packages found in the resource tree");
for (const QString& package : packages) {
const QString archive = qsl(":/packages/%1/%1.mpackage").arg(package);
QVERIFY2(QFile::exists(archive), qPrintable(qsl("%1 holds no archive named after it").arg(package)));
QTemporaryFile onDisk;
QVERIFY(onDisk.open());
QFile resource(archive);
QVERIFY(resource.open(QIODevice::ReadOnly));
QVERIFY(onDisk.write(resource.readAll()) != -1);
onDisk.close();
QTemporaryDir unpacked;
QVERIFY(unpacked.isValid());
// mudlet::unzip() joins the destination and the entry name as-is,
// so the trailing slash is what keeps the files inside the folder:
const QString destination = qsl("%1/").arg(unpacked.path());
QVERIFY2(mudlet::unzip(onDisk.fileName(), destination, QDir(unpacked.path())), qPrintable(qsl("%1 could not be unzipped").arg(archive)));
const QDir contents(unpacked.path());
QVERIFY2(contents.exists(qsl("config.lua")), qPrintable(qsl("%1 carries no config.lua, so it would install without any metadata").arg(archive)));
const QStringList xmls = contents.entryList(QStringList{qsl("*.xml")}, QDir::Files);
QCOMPARE(xmls.count(), 1);
// Mudlet names the installed package after config.lua, so keeping
// the directory named the same is what makes the paths guessable.
const QString declaredName = mpHost->getPackageConfig(contents.absoluteFilePath(qsl("config.lua")));
QVERIFY2(!declaredName.isEmpty(), qPrintable(qsl("%1 declares no package name in its config.lua").arg(archive)));
QCOMPARE(declaredName, scmInstallsAs.value(package, package));
}
}
};
void initializeQRCResourcesForDefaultPackagesTest()
{
#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN
qInitResources_additional_splash_screens();
#endif
#ifdef INCLUDE_FONTS
qInitResources_mudlet_fonts_common();
#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD)
qInitResources_mudlet_fonts_posix();
#endif
#endif
qInitResources_mudlet();
qInitResources_qm();
}
#include "DefaultPackagesTest.moc"
QTEST_MAIN(DefaultPackagesTest)