mirror of
https://github.com/Mudlet/Mudlet
synced 2026-08-13 18:26:27 -04:00
#### 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
353 lines
16 KiB
C++
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)
|