mudlet/test/functional_tests/ExperiencedPlayerGateTest.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

353 lines
16 KiB
C++

/***************************************************************************
* Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org *
* *
* 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. *
***************************************************************************/
/*
* Locks in who Mudlet considers an experienced player. That decision gates all
* of the first-time guidance - the interface tour, the starter UI package and
* the one-line hints - so getting it wrong either buries a newcomer's
* onboarding or drops a beginner tour on top of a ten-year veteran's session.
*
* mudlet::rememberFirstLaunch() and mudlet::evaluateExperiencedPlayer() take
* their settings, profiles path and "now" as arguments, so most cases run
* without a mudlet instance. The last two drive a real init() instead, to pin
* the production wiring.
*
* Run with: ctest -R ExperiencedPlayerGateTest -V
*/
#include <QtTest/QtTest>
#include <QTimeZone>
#include "MudletInstanceCoordinator.h"
#include "mudlet.h"
// init() reads compiled-in resources, which are not registered automatically in
// a test binary that links mudlet_core statically
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 initializeQRCResourcesForExperiencedPlayerGateTest();
class ExperiencedPlayerGateTest : public QObject
{
Q_OBJECT
private:
QByteArray mSavedXdg;
// Outlives the two live-singleton cases, which share one mudlet instance
QTemporaryDir mLiveConfig;
// Fixed, so the six month arithmetic does not depend on the day the suite runs
const QDateTime mNow = QDateTime(QDate(2026, 8, 5), QTime(12, 0), QTimeZone::UTC);
const QString mKey = qsl("firstLaunchDate");
QString profilesPathIn(const QString& configDir) const { return qsl("%1/profiles").arg(configDir); }
QString iniIn(const QString& configDir) const { return qsl("%1/Mudlet.ini").arg(configDir); }
QString makeProfile(const QString& configDir, const QString& name) const
{
const QString path = qsl("%1/%2").arg(profilesPathIn(configDir), name);
return QDir().mkpath(path) ? path : QString();
}
void setFirstLaunch(QSettings& settings, const QDateTime& when) const { settings.setValue(mKey, when.toUTC().toString(Qt::ISODate)); }
// setupConfig() consults portable.txt before the XDG logic
bool portableMarkerPresent() const
{
return QFileInfo::exists(qsl("%1/portable.txt").arg(QCoreApplication::applicationDirPath())) || QFileInfo::exists(qsl("%1/.config/mudlet/portable.txt").arg(QDir::homePath()));
}
private slots:
void initTestCase() { mSavedXdg = qgetenv("XDG_CONFIG_HOME"); }
void cleanupTestCase()
{
if (mudlet::self()) {
delete mudlet::self();
}
mSavedXdg.isNull() ? qunsetenv("XDG_CONFIG_HOME") : qputenv("XDG_CONFIG_HOME", mSavedXdg);
}
// --- a brand new installation ---
void test_freshInstallRecordsTodayAndIsNew()
{
QTemporaryDir config;
QVERIFY(config.isValid());
QSettings settings(iniIn(config.path()), QSettings::IniFormat);
QVERIFY(!QDir(profilesPathIn(config.path())).exists());
mudlet::rememberFirstLaunch(settings, profilesPathIn(config.path()), mNow);
QCOMPARE(settings.value(mKey).toString(), mNow.toString(Qt::ISODate));
QVERIFY2(!mudlet::evaluateExperiencedPlayer(settings, profilesPathIn(config.path()), mNow), "a first-ever launch must be treated as a new player");
}
void test_emptyProfilesDirectoryIsStillAFirstRun()
{
QTemporaryDir config;
QVERIFY(config.isValid());
QVERIFY(QDir().mkpath(profilesPathIn(config.path())));
QSettings settings(iniIn(config.path()), QSettings::IniFormat);
mudlet::rememberFirstLaunch(settings, profilesPathIn(config.path()), mNow);
QVERIFY(settings.contains(mKey));
QVERIFY(!mudlet::evaluateExperiencedPlayer(settings, profilesPathIn(config.path()), mNow));
}
// --- the recorded date, once there is one ---
void test_recentlyRecordedFirstLaunchIsNew()
{
QTemporaryDir config;
QVERIFY(config.isValid());
QSettings settings(iniIn(config.path()), QSettings::IniFormat);
setFirstLaunch(settings, mNow.addMonths(-1));
QVERIFY(!makeProfile(config.path(), qsl("Achaea")).isEmpty());
QVERIFY2(!mudlet::evaluateExperiencedPlayer(settings, profilesPathIn(config.path()), mNow), "a month of use is not enough to be experienced, even with a profile in hand");
}
void test_oldRecordedFirstLaunchIsExperienced()
{
QTemporaryDir config;
QVERIFY(config.isValid());
QSettings settings(iniIn(config.path()), QSettings::IniFormat);
setFirstLaunch(settings, mNow.addYears(-3));
QVERIFY(mudlet::evaluateExperiencedPlayer(settings, profilesPathIn(config.path()), mNow));
}
void test_sixMonthBoundary()
{
QTemporaryDir config;
QVERIFY(config.isValid());
QSettings settings(iniIn(config.path()), QSettings::IniFormat);
const QString profiles = profilesPathIn(config.path());
setFirstLaunch(settings, mNow.addMonths(-6).addDays(1));
QVERIFY2(!mudlet::evaluateExperiencedPlayer(settings, profiles, mNow), "one day short of six months is not yet experienced");
setFirstLaunch(settings, mNow.addMonths(-6));
QVERIFY2(mudlet::evaluateExperiencedPlayer(settings, profiles, mNow), "exactly six months of use is experienced");
setFirstLaunch(settings, mNow.addMonths(-6).addDays(-1));
QVERIFY(mudlet::evaluateExperiencedPlayer(settings, profiles, mNow));
}
void test_futureDatedFirstLaunchIsNotExperienced()
{
QTemporaryDir config;
QVERIFY(config.isValid());
QSettings settings(iniIn(config.path()), QSettings::IniFormat);
setFirstLaunch(settings, mNow.addYears(1));
QVERIFY(!mudlet::evaluateExperiencedPlayer(settings, profilesPathIn(config.path()), mNow));
}
// --- upgrading users, who have no recorded first launch ---
void test_upgraderWithFreshlyWrittenProfilesIsExperienced()
{
QTemporaryDir config;
QVERIFY(config.isValid());
QSettings settings(iniIn(config.path()), QSettings::IniFormat);
for (const auto& name : {qsl("Achaea"), qsl("StickMUD"), qsl("Legends of the Jedi")}) {
const QString profile = makeProfile(config.path(), name);
QVERIFY(!profile.isEmpty());
QFile url(qsl("%1/url").arg(profile));
QVERIFY(url.open(QIODevice::WriteOnly));
url.write("achaea.com");
url.close();
QVERIFY2(QFileInfo(profile).lastModified() > mNow.addMonths(-6), "the fixture is only meaningful while the profile directory looks brand new");
}
QVERIFY2(mudlet::evaluateExperiencedPlayer(settings, profilesPathIn(config.path()), mNow), "an installation with profiles but no recorded first launch predates the key, so it is experienced");
}
// A restored profile may or may not keep its modification times, and never
// keeps its birth time, so no timestamp is consulted
void test_restoredFromBackupIsExperienced()
{
QTemporaryDir config;
QVERIFY(config.isValid());
QSettings settings(iniIn(config.path()), QSettings::IniFormat);
QVERIFY(!makeProfile(config.path(), qsl("Restored")).isEmpty());
QVERIFY(mudlet::evaluateExperiencedPlayer(settings, profilesPathIn(config.path()), mNow));
}
void test_settingsWithoutProfilesStillCountAsEarlierUse()
{
QTemporaryDir config;
QVERIFY(config.isValid());
QSettings settings(iniIn(config.path()), QSettings::IniFormat);
settings.setValue(qsl("pos"), QPoint(120, 80));
QVERIFY(!QDir(profilesPathIn(config.path())).exists());
mudlet::rememberFirstLaunch(settings, profilesPathIn(config.path()), mNow);
QVERIFY2(!settings.contains(mKey), "an installation with settings on file is not on its first run");
QVERIFY(mudlet::evaluateExperiencedPlayer(settings, profilesPathIn(config.path()), mNow));
}
void test_unreadableProfilesDirectoryIsTakenAsPopulated()
{
QTemporaryDir config;
QVERIFY(config.isValid());
QSettings settings(iniIn(config.path()), QSettings::IniFormat);
const QString profiles = profilesPathIn(config.path());
QVERIFY(!makeProfile(config.path(), qsl("Achaea")).isEmpty());
QVERIFY(QFile::setPermissions(profiles, QFileDevice::WriteOwner | QFileDevice::ExeOwner));
if (QFileInfo(profiles).isReadable()) {
QVERIFY(QFile::setPermissions(profiles, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner));
QSKIP("the profiles directory is readable despite the permissions - running as root?");
}
const bool experienced = mudlet::evaluateExperiencedPlayer(settings, profiles, mNow);
// Restore before asserting, or a failure leaves QTemporaryDir unable to clean up
QVERIFY(QFile::setPermissions(profiles, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner));
QVERIFY(experienced);
}
void test_upgradeDoesNotRecordAFirstLaunch()
{
QTemporaryDir config;
QVERIFY(config.isValid());
QSettings settings(iniIn(config.path()), QSettings::IniFormat);
QVERIFY(!makeProfile(config.path(), qsl("Achaea")).isEmpty());
mudlet::rememberFirstLaunch(settings, profilesPathIn(config.path()), mNow);
QVERIFY(!settings.contains(mKey));
QVERIFY(mudlet::evaluateExperiencedPlayer(settings, profilesPathIn(config.path()), mNow.addYears(1)));
}
void test_existingRecordIsNeverOverwritten()
{
QTemporaryDir config;
QVERIFY(config.isValid());
QSettings settings(iniIn(config.path()), QSettings::IniFormat);
const QDateTime original = mNow.addMonths(-3);
setFirstLaunch(settings, original);
mudlet::rememberFirstLaunch(settings, profilesPathIn(config.path()), mNow);
QCOMPARE(settings.value(mKey).toString(), original.toString(Qt::ISODate));
}
void test_unparseableRecordFallsBackToTheEarlierUseCheck()
{
QTemporaryDir config;
QVERIFY(config.isValid());
QSettings settings(iniIn(config.path()), QSettings::IniFormat);
settings.setValue(mKey, qsl("not a date"));
QVERIFY(mudlet::evaluateExperiencedPlayer(settings, profilesPathIn(config.path()), mNow));
mudlet::rememberFirstLaunch(settings, profilesPathIn(config.path()), mNow);
QCOMPARE(settings.value(mKey).toString(), qsl("not a date"));
}
void test_recordedValueSurvivesAQSettingsRoundTrip()
{
QTemporaryDir config;
QVERIFY(config.isValid());
{
QSettings writer(iniIn(config.path()), QSettings::IniFormat);
mudlet::rememberFirstLaunch(writer, profilesPathIn(config.path()), mNow.addYears(-2));
}
QSettings reader(iniIn(config.path()), QSettings::IniFormat);
QVERIFY2(mudlet::evaluateExperiencedPlayer(reader, profilesPathIn(config.path()), mNow), "the recorded date must be readable back out of Mudlet.ini");
QFile ini(iniIn(config.path()));
QVERIFY(ini.open(QIODevice::ReadOnly | QIODevice::Text));
QVERIFY2(QString::fromUtf8(ini.readAll()).contains(qsl("firstLaunchDate=2024-08-05T12:00:00Z")), "the date is stored as plain ISO 8601, so it can be read and edited by hand");
}
// --- the live singleton ---
// Nothing else in the suite notices the init() call being moved or dropped,
// which would make every fresh install take the "used before" fallback
void test_initRecordsTheFirstLaunchOnAFreshInstall()
{
if (portableMarkerPresent()) {
QSKIP("portable.txt present - setupConfig() takes the portable branch");
}
QVERIFY(mLiveConfig.isValid());
// $XDG_CONFIG_HOME/mudlet/profiles is the opt-in marker, without which
// setupConfig() keeps using a legacy ~/.config/mudlet
const QString configDir = qsl("%1/mudlet").arg(mLiveConfig.path());
QVERIFY(QDir().mkpath(qsl("%1/profiles").arg(configDir)));
qputenv("XDG_CONFIG_HOME", mLiveConfig.path().toUtf8());
initializeQRCResourcesForExperiencedPlayerGateTest();
mudlet::start();
mudlet::self()->setupConfig();
QCOMPARE(mudlet::getMudletPath(enums::mainPath), configDir);
QVERIFY2(mudlet::getQSettings()->allKeys().isEmpty(), "a fresh config dir must start out with an empty Mudlet.ini - something wrote settings before init()");
QVERIFY2(QDir(mudlet::getMudletPath(enums::profilesPath)).entryList(QDir::Dirs | QDir::NoDotAndDotDot).isEmpty(), "the opt-in profiles/ dir has to be empty, or this is not a fresh install");
mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator"));
mudlet::self()->init();
QVERIFY2(mudlet::getQSettings()->contains(mKey), "init() must record the first launch date");
QCOMPARE(QDateTime::fromString(mudlet::getQSettings()->value(mKey).toString(), Qt::ISODate).isValid(), true);
}
// Pins the key and profiles path experiencedMudletPlayer() picks for itself,
// which the case above cannot - there both branches would answer "new".
// Runs last: experiencedMudletPlayer() memoises for the life of the process.
void test_experiencedThroughTheRealSettings()
{
if (portableMarkerPresent()) {
QSKIP("portable.txt present - setupConfig() takes the portable branch");
}
QVERIFY(mudlet::self());
auto* settings = mudlet::getQSettings();
QVERIFY(settings);
settings->remove(mKey);
QVERIFY(!makeProfile(mudlet::getMudletPath(enums::mainPath), qsl("Achaea")).isEmpty());
QVERIFY2(mudlet::self()->experiencedMudletPlayer(), "a profile with no recorded first launch must read as an experienced player");
}
};
void initializeQRCResourcesForExperiencedPlayerGateTest()
{
#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 "ExperiencedPlayerGateTest.moc"
QTEST_MAIN(ExperiencedPlayerGateTest)