mudlet/test/functional_tests/ResetProfileTest.cpp
Vadim Peretokin 69cd06b1c2
add: starter interface with health bars, map and chat for new players (#9454)
#### Brief overview of PR changes/additions
- New "Mudlet base UI" package auto-installed for new players on every
game: adjustable right-side dock with map (placeholder until the game is
mapped), tabbed chat (All/Tells/Channels with unread badges) and themed
HP/MP/MV/XP gauges - only ever built from data the game actually
provides
- Chat capture: GMCP Comm.Channel.Text when available, otherwise a
generic additive trigger layer (line shapes distilled from an audit of
all 216 packages in the Mudlet package repository); nothing is ever
gagged, and the trigger layer retires itself the moment GMCP chat
appears
- Vitals ladder for the gauges: GMCP (several dialect spellings) > MSDP
(negotiated automatically) > self-sufficient prompts (labeled cur/max or
percents) > score-screen harvesting; when a prompt only shows bare
currents, Mudlet sends `score` once - visibly, with an announcement - to
learn the maxima. Maxima are never guessed. An enemy-health bar appears
during fights on games that report it
- `baseui hide` / `baseui show` opt-out persists across restarts;
first-run announcement defers to the UI tour; experienced players never
get the package
- Plays nice with games that provide their own interface: profiles for
games whose bundled loader installs the game's official UI (flagged in
TGameDetails: Carrion Fields, Icesus, MorgenGrauen, Medievia) skip the
starter UI entirely, and when a game pushes a `Client.GUI` package the
starter UI quietly stands aside (via the new `sysServerGuiInstalled`
event) - `baseui show` brings it back for players who prefer it
- `docs/package-capture-audits/` records the chat + vitals capture
audits (with provenance) behind the design

#### Motivation for adding to Mudlet
New players currently get a bare text screen; this gives every game -
GMCP-rich, MSDP-only or plain telnet - an immediate, honest starter UI
out of the box.

#### Other info (issues closed, discussion etc)
Progresses #3071 and #3070 (phase 1 - not closing them).

**Test case:** Fresh install → create a profile for any game → connect.
On GMCP/MSDP games the dock builds with ticking gauges and tabbed chat;
on a game whose prompt carries cur/max values, gauges appear once the
prompt repeats; on a bare-number prompt, `score` is sent once
(announced) to size the gauges; on pure chat games, tells/says/channels
are captured with no gauges invented. `baseui hide` removes it and
survives restart.



https://github.com/user-attachments/assets/56d207cc-5bad-4464-867c-1a810d41cf2f

---------

Signed-off-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
2026-07-26 08:43:21 +02:00

931 lines
33 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. *
***************************************************************************/
/*
* Comprehensive tests for Host::resetProfile_phase1() / resetProfile_phase2().
* Covers temp item removal, permanent item survival, stopwatches, events,
* Lua state reinit, sysLoadEvent, flags, UI cleanup, two-phase correctness,
* ANSI colors, map survival, telnet connection, Geyser reload, double reset,
* and script event handler re-registration.
*
* The mudlet instance and profile are created once in initTestCase().
* Each test calls resetProfile() at the end (via cleanup()) to restore
* a clean state for the next test.
*
* Run with: ctest -R ResetProfileTest -V
*/
#include <QtTest/QtTest>
#include <chrono>
#include <QMouseEvent>
#include "Host.h"
#include "LuaInterface.h"
#include "MudletInstanceCoordinator.h"
#include "TEvent.h"
#include "TKey.h"
#include "TLabel.h"
#include "TLuaInterpreter.h"
#include "TMainConsole.h"
#include "TMap.h"
#include "TRoomDB.h"
#include "TScript.h"
#include "TTimer.h"
#include "TTrigger.h"
#include "TVar.h"
#include "TelnetServerStub.h"
#include "VarUnit.h"
#include "XMLexport.h"
#include "ctelnet.h"
#include "dlgConnectionProfiles.h"
#include "mudlet.h"
extern "C" {
#if defined(INCLUDE_VERSIONED_LUA_HEADERS)
#include <lua5.1/lauxlib.h>
#include <lua5.1/lua.h>
#include <lua5.1/lualib.h>
#else
#include <lauxlib.h>
#include <lua.h>
#include <lualib.h>
#endif
}
using namespace std::chrono_literals;
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 initializeQRCResourcesForResetProfileTest();
class ResetProfileTest : public QObject {
Q_OBJECT
private:
TelnetServerStub *mpServer = nullptr;
Host *mpHost = nullptr;
const QString mHostname = "ResetProfile-Test";
QString mPort; // assigned the stub's actual ephemeral port in initTestCase()
const QString mLocalhost = "localhost";
void performReset() {
mpHost->resetProfile_phase1();
QCoreApplication::processEvents();
}
int countTempTriggers() {
int count = 0;
for (auto *trigger : mpHost->getTriggerUnit()->getTriggerRootNodeList()) {
if (trigger->isTemporary()) {
++count;
}
}
return count;
}
int countTempKeys() {
int count = 0;
for (auto *key : mpHost->getKeyUnit()->getKeyRootNodeList()) {
if (key->isTemporary()) {
++count;
}
}
return count;
}
private slots:
void initTestCase() {
initializeQRCResourcesForResetProfileTest();
mpServer = new TelnetServerStub(qApp);
mpServer->start(mLocalhost, 0); // ephemeral OS-assigned port avoids collisions across concurrent test runs
mPort = QString::number(mpServer->serverPort());
mudlet::start();
mudlet::self()->setupConfig();
mudlet::self()->takeOwnershipOfInstanceCoordinator(
std::make_unique<MudletInstanceCoordinator>(
"MudletInstanceCoordinator"));
mudlet::self()->init();
mudlet::self()->setStorePasswordsSecurely(false);
deleteProfileDirectory(mHostname);
startProfile(mHostname, mLocalhost, mPort);
mpHost = mudlet::self()->getActiveHost();
QVERIFY2(mpHost, "No active host after profile creation");
}
void cleanupTestCase() {
mpHost = nullptr;
delete mpServer;
mpServer = nullptr;
deleteProfileDirectory(mHostname);
delete mudlet::self();
}
// Per-test cleanup: reset the profile so each test starts from clean state.
// Tests that exercise resetProfile() themselves may leave the profile already
// reset, which is fine — a second reset is harmless.
void cleanup() {
if (mpHost && !mpHost->mResetProfile) {
performReset();
} else if (mpHost && mpHost->mResetProfile) {
QCoreApplication::processEvents();
}
}
// -----------------------------------------------------------------------
// Group 1: Temporary Item Removal (#761)
// -----------------------------------------------------------------------
void test_tempTriggersRemovedAfterReset() {
// preinstalled packages (the starter UI) register their own temp triggers
// and re-register them when the profile comes back, so the count after a
// reset returns to that baseline rather than zero
const int baseline = countTempTriggers();
int id = mpHost->mLuaInterpreter.startTempTrigger(qsl("test_pattern"),
qsl(""), -1);
QVERIFY(id > 0);
QVERIFY(countTempTriggers() > baseline);
performReset();
QCOMPARE(countTempTriggers(), baseline);
}
void test_tempAliasesRemovedAfterReset() {
int id =
mpHost->mLuaInterpreter.startTempAlias(qsl("^test_alias$"), qsl(""));
QVERIFY(id > 0);
QVERIFY(mpHost->getAliasUnit()->getAlias(id) != nullptr);
performReset();
// Our specific temp alias should be gone (Lua scripts may create their own
// via sysLoadEvent)
QVERIFY2(mpHost->getAliasUnit()->getAlias(id) == nullptr,
"Temp alias should be removed after reset");
}
void test_tempTimersRemovedAfterReset() {
auto result = mpHost->mLuaInterpreter.startTempTimer(60.0, qsl(""));
int id = result.first;
QVERIFY(id > 0);
QVERIFY(mpHost->getTimerUnit()->getTimer(id) != nullptr);
performReset();
QVERIFY2(mpHost->getTimerUnit()->getTimer(id) == nullptr,
"Temp timer should be removed after reset");
}
void test_tempKeysRemovedAfterReset() {
int modifier = Qt::NoModifier;
int keycode = Qt::Key_F12;
int id = mpHost->mLuaInterpreter.startTempKey(modifier, keycode, qsl(""));
QVERIFY(id > 0);
QVERIFY(countTempKeys() > 0);
performReset();
QCOMPARE(countTempKeys(), 0);
}
// -----------------------------------------------------------------------
// Group 2: Permanent Item Survival
// -----------------------------------------------------------------------
void test_permanentTriggerSurvivesReset() {
QStringList patterns;
patterns << qsl("perm_test_pattern");
auto [id, msg] = mpHost->mLuaInterpreter.startPermSubstringTrigger(
qsl("perm_trigger"), qsl(""), patterns, qsl(""));
QVERIFY2(id > 0, qPrintable(msg));
performReset();
bool found = false;
for (auto *trigger : mpHost->getTriggerUnit()->getTriggerRootNodeList()) {
if (trigger->getID() == id) {
found = true;
QVERIFY(trigger->isActive());
break;
}
}
QVERIFY2(found, "Permanent trigger not found after reset");
}
void test_permanentAliasSurvivesReset() {
auto [id, msg] = mpHost->mLuaInterpreter.startPermAlias(
qsl("perm_alias"), qsl(""), qsl("^perm_test$"), qsl(""));
QVERIFY2(id > 0, qPrintable(msg));
performReset();
bool found = false;
for (auto *alias : mpHost->getAliasUnit()->getAliasRootNodeList()) {
if (alias->getID() == id) {
found = true;
QVERIFY(alias->isActive());
break;
}
}
QVERIFY2(found, "Permanent alias not found after reset");
}
void test_permanentTimerSurvivesReset() {
auto [id, msg] = mpHost->mLuaInterpreter.startPermTimer(
qsl("perm_timer"), qsl(""), 300.0, qsl(""));
QVERIFY2(id > 0, qPrintable(msg));
performReset();
bool found = false;
for (auto *timer : mpHost->getTimerUnit()->getTimerRootNodeList()) {
if (timer->getID() == id) {
found = true;
break;
}
}
QVERIFY2(found, "Permanent timer not found after reset");
}
// -----------------------------------------------------------------------
// Group 3: Stopwatch Handling (#4715)
// -----------------------------------------------------------------------
void test_nonPersistentStopWatchRemovedAfterReset() {
mpHost->mBlockStopWatchCreation = false;
auto result = mpHost->createStopWatch(qsl("test_sw"));
int id = result.first;
QVERIFY2(id > 0, qPrintable(result.second));
QVERIFY(mpHost->getStopWatch(id) != nullptr);
performReset();
QVERIFY2(mpHost->getStopWatch(id) == nullptr,
"Non-persistent stopwatch should be removed after reset");
}
void test_persistentStopWatchSurvivesReset() {
mpHost->mBlockStopWatchCreation = false;
auto result = mpHost->createStopWatch(qsl("persistent_sw"));
int id = result.first;
QVERIFY2(id > 0, qPrintable(result.second));
mpHost->makeStopWatchPersistent(id, true);
performReset();
auto *sw = mpHost->getStopWatch(id);
QVERIFY2(sw != nullptr, "Persistent stopwatch should survive reset");
QVERIFY(sw->persistent());
}
void test_stopWatchCreationBlockedDuringReset() {
mpHost->mBlockStopWatchCreation = false;
mpHost->resetProfile_phase1();
// mResetProfile is now true — stopwatch creation should be blocked
auto result = mpHost->createStopWatch(qsl("blocked_sw"));
QCOMPARE(result.first, 0);
QCoreApplication::processEvents();
// After phase2, creation should work again
mpHost->mBlockStopWatchCreation = false;
auto result2 = mpHost->createStopWatch(qsl("unblocked_sw"));
QVERIFY2(result2.first > 0,
"Stopwatch creation should work after reset completes");
}
// -----------------------------------------------------------------------
// Group 4: Event System Cleanup (#4970, #7698)
// -----------------------------------------------------------------------
void test_eventHandlerMapClearedAfterReset() {
mpHost->mEventHandlerMap[qsl("testEvent")] = QList<TScript *>();
QVERIFY(!mpHost->mEventHandlerMap.isEmpty());
performReset();
// mEventHandlerMap is cleared, then repopulated by compileAll
// re-registering script handlers. Our manually-inserted "testEvent" entry
// should be gone.
QVERIFY2(!mpHost->mEventHandlerMap.contains(qsl("testEvent")),
"Manually inserted event handler should be cleared after reset");
}
void test_anonymousEventHandlersClearedAfterReset() {
lua_State *L = mpHost->mLuaInterpreter.getLuaGlobalState();
luaL_dostring(
L, "function anonHandlerTestFunc() anonHandlerCalled = true end");
mpHost->registerAnonymousEventHandler(qsl("testAnonymousEvent"),
qsl("anonHandlerTestFunc"));
performReset();
// The Lua function no longer exists in the new state
lua_State *newL = mpHost->mLuaInterpreter.getLuaGlobalState();
lua_getglobal(newL, "anonHandlerTestFunc");
QVERIFY2(lua_isnil(newL, -1), "Lua function referenced by anonymous "
"handler should not exist in new Lua state");
lua_pop(newL, 1);
// Raise the event - if the anonymous handler map was properly cleared,
// this won't attempt to call the now-nonexistent function
TEvent event{};
event.mArgumentList.append(qsl("testAnonymousEvent"));
event.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
mpHost->raiseEvent(event);
}
// -----------------------------------------------------------------------
// Group 5: Lua State Reinitialization (#3692)
// -----------------------------------------------------------------------
void test_luaCustomGlobalsClearedAfterReset() {
lua_State *L = mpHost->mLuaInterpreter.getLuaGlobalState();
QVERIFY(L);
luaL_dostring(L, "resetProfileTestGlobal = 42");
lua_getglobal(L, "resetProfileTestGlobal");
QVERIFY(!lua_isnil(L, -1));
lua_pop(L, 1);
performReset();
lua_State *newL = mpHost->mLuaInterpreter.getLuaGlobalState();
lua_getglobal(newL, "resetProfileTestGlobal");
QVERIFY2(lua_isnil(newL, -1),
"Custom Lua global should be gone after reset");
lua_pop(newL, 1);
}
void test_luaStandardFunctionsAvailableAfterReset() {
performReset();
lua_State *L = mpHost->mLuaInterpreter.getLuaGlobalState();
QVERIFY(L);
lua_getglobal(L, "assert");
QVERIFY2(!lua_isnil(L, -1), "assert should be available after reset");
lua_pop(L, 1);
lua_getglobal(L, "pcall");
QVERIFY2(!lua_isnil(L, -1), "pcall should be available after reset");
lua_pop(L, 1);
lua_getglobal(L, "string");
QVERIFY2(!lua_isnil(L, -1),
"string library should be available after reset");
lua_pop(L, 1);
lua_getglobal(L, "table");
QVERIFY2(!lua_isnil(L, -1),
"table library should be available after reset");
lua_pop(L, 1);
}
void test_luaStateIsNewAfterReset() {
lua_State *L = mpHost->mLuaInterpreter.getLuaGlobalState();
QVERIFY(L);
luaL_dostring(L, "resetTestSentinel = 42");
performReset();
lua_State *newL = mpHost->mLuaInterpreter.getLuaGlobalState();
QVERIFY(newL);
lua_getglobal(newL, "resetTestSentinel");
QVERIFY2(lua_isnil(newL, -1), "Lua state should be fresh after reset");
lua_pop(newL, 1);
}
// -----------------------------------------------------------------------
// Group 6: sysLoadEvent (#4779, #5005)
// -----------------------------------------------------------------------
void test_sysLoadEventFiredExactlyOnce() {
auto *pScript = new TScript(qsl("sysLoadEventCounter"), mpHost);
pScript->setScript(qsl("sysLoadCounter = (sysLoadCounter or 0) + 1"));
QStringList events;
events << qsl("sysLoadEvent");
pScript->setEventHandlerList(events);
mpHost->getScriptUnit()->registerScript(pScript);
pScript->setIsActive(true);
pScript->compile();
performReset();
lua_State *L = mpHost->mLuaInterpreter.getLuaGlobalState();
lua_getglobal(L, "sysLoadCounter");
QVERIFY2(!lua_isnil(L, -1),
"sysLoadCounter should be set by the event handler");
int counter = lua_tointeger(L, -1);
lua_pop(L, 1);
QCOMPARE(counter, 1);
}
// -----------------------------------------------------------------------
// Group 7: Flag Lifecycle
// -----------------------------------------------------------------------
void test_mResetProfileFlagLifecycle() {
QVERIFY2(!mpHost->mResetProfile,
"mResetProfile should be false before reset");
mpHost->resetProfile_phase1();
QVERIFY2(mpHost->mResetProfile,
"mResetProfile should be true after phase1");
QCoreApplication::processEvents();
QVERIFY2(!mpHost->mResetProfile,
"mResetProfile should be false after phase2");
}
void test_mBlockScriptCompileFalseAfterReset() {
mpHost->mBlockScriptCompile = true;
performReset();
QVERIFY2(!mpHost->mBlockScriptCompile,
"mBlockScriptCompile should be false after reset");
}
void test_mEmergencyStopNotSetDuringReset() {
QVERIFY2(!mpHost->mEmergencyStop,
"mEmergencyStop should be false before reset");
mpHost->resetProfile_phase1();
QVERIFY2(!mpHost->mEmergencyStop,
"mEmergencyStop should stay false during phase1 (phase1 calls "
"unit-level stop, not Host::stopAllTriggers)");
QCoreApplication::processEvents();
QVERIFY2(!mpHost->mEmergencyStop,
"mEmergencyStop should stay false after phase2");
}
// -----------------------------------------------------------------------
// Group 8: UI Cleanup
// -----------------------------------------------------------------------
void test_miniConsolesRemovedAfterReset() {
auto [ok, msg] =
mpHost->createMiniConsole(qsl("main"), qsl("test_mc"), 0, 0, 100, 100);
QVERIFY2(ok, qPrintable(msg));
QVERIFY(mpHost->mpConsole->mSubConsoleMap.contains(qsl("test_mc")));
performReset();
QVERIFY2(!mpHost->mpConsole->mSubConsoleMap.contains(qsl("test_mc")),
"Mini console should be removed after reset");
}
void test_labelsRemovedAfterReset() {
auto [ok, msg] = mpHost->createLabel(qsl("main"), qsl("test_label"), 0, 0,
100, 100, true, false);
QVERIFY2(ok, qPrintable(msg));
QVERIFY(mpHost->mpConsole->mLabelMap.contains(qsl("test_label")));
performReset();
QVERIFY2(!mpHost->mpConsole->mLabelMap.contains(qsl("test_label")),
"Label should be removed after reset");
}
// -----------------------------------------------------------------------
// Group 9: Two-Phase Correctness
// -----------------------------------------------------------------------
void test_phase2RunsDeferredNotImmediate() {
const int baseline = countTempTriggers();
int triggerId = mpHost->mLuaInterpreter.startTempTrigger(
qsl("deferred_test"), qsl(""), -1);
QVERIFY(triggerId > 0);
mpHost->resetProfile_phase1();
// Phase2 has NOT run yet
QVERIFY2(mpHost->mResetProfile,
"mResetProfile should be true before processEvents");
QVERIFY2(countTempTriggers() > baseline,
"Temp triggers should still exist before processEvents");
QCoreApplication::processEvents();
QVERIFY2(!mpHost->mResetProfile,
"mResetProfile should be false after processEvents");
QCOMPARE(countTempTriggers(), baseline);
}
// -----------------------------------------------------------------------
// Group 10: ANSI Color Table
// -----------------------------------------------------------------------
void test_ansiColorTableUpdatedAfterReset() {
mpHost->mBlack = QColor(10, 20, 30);
performReset();
lua_State *L = mpHost->mLuaInterpreter.getLuaGlobalState();
QVERIFY(L);
lua_getglobal(L, "color_table");
QVERIFY2(!lua_isnil(L, -1), "color_table should exist after reset");
lua_getfield(L, -1, "ansi_000");
QVERIFY2(!lua_isnil(L, -1), "color_table.ansi_000 should exist");
lua_rawgeti(L, -1, 1);
int r = lua_tointeger(L, -1);
lua_pop(L, 1);
lua_rawgeti(L, -1, 2);
int g = lua_tointeger(L, -1);
lua_pop(L, 1);
lua_rawgeti(L, -1, 3);
int b = lua_tointeger(L, -1);
lua_pop(L, 1);
lua_pop(L, 2);
QCOMPARE(r, 10);
QCOMPARE(g, 20);
QCOMPARE(b, 30);
}
// -----------------------------------------------------------------------
// Group 11: Map System Survival
// -----------------------------------------------------------------------
void test_mapDataSurvivesReset() {
QVERIFY(mpHost->mpMap);
int roomId = mpHost->mpMap->createNewRoomID();
QVERIFY(roomId > 0);
QVERIFY(mpHost->mpMap->addRoom(roomId));
performReset();
QVERIFY2(mpHost->mpMap, "TMap pointer should survive reset");
auto *room = mpHost->mpMap->mpRoomDB->getRoom(roomId);
QVERIFY2(room != nullptr, "Room added before reset should still exist");
}
// -----------------------------------------------------------------------
// Group 12: Telnet Connection Survives
// -----------------------------------------------------------------------
void test_telnetConnectionSurvivesReset() {
auto stateBefore = mpHost->mTelnet.getConnectionState();
QCOMPARE(stateBefore, QAbstractSocket::ConnectedState);
performReset();
auto stateAfter = mpHost->mTelnet.getConnectionState();
QCOMPARE(stateAfter, QAbstractSocket::ConnectedState);
}
// -----------------------------------------------------------------------
// Group 13: Geyser/LuaGlobal Framework Reload
// -----------------------------------------------------------------------
void test_geyserFrameworkAvailableAfterReset() {
performReset();
lua_State *L = mpHost->mLuaInterpreter.getLuaGlobalState();
QVERIFY(L);
lua_getglobal(L, "Geyser");
QVERIFY2(!lua_isnil(L, -1),
"Geyser framework should be loaded after reset");
lua_pop(L, 1);
}
void test_gmcpTableFreshAfterReset() {
lua_State *L = mpHost->mLuaInterpreter.getLuaGlobalState();
luaL_dostring(L, "gmcp.testField = 'stale_data'");
performReset();
lua_State *newL = mpHost->mLuaInterpreter.getLuaGlobalState();
lua_getglobal(newL, "gmcp");
QVERIFY2(!lua_isnil(newL, -1), "gmcp table should exist after reset");
lua_getfield(newL, -1, "testField");
QVERIFY2(lua_isnil(newL, -1),
"gmcp.testField should be nil after reset (fresh table)");
lua_pop(newL, 2);
}
// -----------------------------------------------------------------------
// Group 14: Double Reset Safety
// -----------------------------------------------------------------------
void test_doubleResetIsGuarded() {
const int baseline = countTempTriggers();
mpHost->mLuaInterpreter.startTempTrigger(qsl("double_reset_test"), qsl(""),
-1);
lua_State *L = mpHost->mLuaInterpreter.getLuaGlobalState();
luaL_dostring(L, "doubleResetSentinel = 99");
// Call phase1 twice - the second call should be a no-op
QVERIFY2(mpHost->resetProfile_phase1(),
"First resetProfile_phase1() should succeed");
QVERIFY2(!mpHost->resetProfile_phase1(),
"Second resetProfile_phase1() should be guarded");
QCoreApplication::processEvents();
// Verify reset actually happened by checking the Lua state is fresh
lua_State *afterL = mpHost->mLuaInterpreter.getLuaGlobalState();
QVERIFY(afterL);
lua_getglobal(afterL, "doubleResetSentinel");
QVERIFY2(lua_isnil(afterL, -1), "Lua state should be fresh after reset");
lua_pop(afterL, 1);
QVERIFY2(!mpHost->mResetProfile,
"mResetProfile should be false after reset");
QCOMPARE(countTempTriggers(), baseline);
}
// -----------------------------------------------------------------------
// Group 15: Script Recompilation and Event Handler Re-registration
// -----------------------------------------------------------------------
void test_permanentScriptEventHandlerReregisteredAfterReset() {
const QString eventName = qsl("customResetTestEvent");
auto *pScript = new TScript(qsl("eventHandlerScript"), mpHost);
pScript->setScript(qsl("-- handler for customResetTestEvent"));
QStringList events;
events << eventName;
pScript->setEventHandlerList(events);
mpHost->getScriptUnit()->registerScript(pScript);
pScript->setIsActive(true);
pScript->compile();
QVERIFY2(mpHost->mEventHandlerMap.contains(eventName),
"Event handler should be registered before reset");
performReset();
QVERIFY2(
mpHost->mEventHandlerMap.contains(eventName),
"Event handler should be re-registered after reset via compileAll");
}
// -----------------------------------------------------------------------
// Group 16: Lua Registry Corruption (TLabel destructor timing)
// -----------------------------------------------------------------------
// PR #9141 added a TLabel destructor that frees its 7 callback indices
// via luaL_unref. resetMainConsole() destroys labels via deleteLater(),
// so without an explicit drain those destructors fire AFTER phase2 has
// swapped in a new Lua state via initLuaGlobals(). The luaL_unref calls
// then write into the new state's registry — any slots already populated
// by compileAll() during the same phase2 (e.g. Adjustable.Container's
// many label callbacks) get overwritten with freelist-chain numbers,
// surfacing as "attempt to call a number value" the first time the user
// hovers a label.
//
// The fix in Host::resetProfile_phase2() drains DeferredDelete events
// between resetMainConsole() and initLuaGlobals(), so the old destructors
// run their unrefs against the still-live original state. This test
// reproduces the conditions: a script that creates many labels with
// click callbacks at top level, so compileAll re-executes it during
// phase2 and populates the new registry with the exact slot indices the
// queued old destructors would have corrupted.
void test_labelCallbacksWorkAfterResetWithManyLabels() {
const int kNumLabels = 50;
auto *pScript = new TScript(qsl("labelCallbackRegressionScript"), mpHost);
pScript->setScript(qsl("labelCallbackHits = 0\n"
"for i = 1, %1 do\n"
" local n = 'cb_label_'..i\n"
" createLabel(n, 0, 0, 10, 10, true)\n"
" setLabelClickCallback(n, function()\n"
" labelCallbackHits = labelCallbackHits + 1\n"
" end)\n"
"end\n")
.arg(kNumLabels));
mpHost->getScriptUnit()->registerScript(pScript);
pScript->setIsActive(true);
pScript->compile();
performReset();
// Force any DeferredDelete events still pending after phase2 to fire
// before we trigger callbacks. With the fix, phase2 already drained
// them; without the fix, this is when corruption would land.
QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete);
for (int i = 1; i <= kNumLabels; ++i) {
auto *pL = mpHost->mpConsole->mLabelMap.value(qsl("cb_label_%1").arg(i));
QVERIFY2(pL, qPrintable(qsl("post-reset label cb_label_%1 missing").arg(i)));
QMouseEvent ev(QEvent::MouseButtonPress, QPointF(1, 1), QPointF(1, 1),
QPointF(1, 1), Qt::LeftButton, Qt::LeftButton,
Qt::NoModifier);
QCoreApplication::sendEvent(pL, &ev);
}
lua_State *L = mpHost->mLuaInterpreter.getLuaGlobalState();
lua_getglobal(L, "labelCallbackHits");
QVERIFY2(lua_isnumber(L, -1),
"labelCallbackHits should be a number after firing callbacks");
int hits = lua_tointeger(L, -1);
lua_pop(L, 1);
QCOMPARE(hits, kNumLabels);
// Neutralize the script so it doesn't keep recreating labels in
// subsequent tests' compileAll passes.
pScript->setScript(qsl(""));
pScript->setIsActive(false);
}
// -----------------------------------------------------------------------
// Group 17: VarUnit saved/hidden variable bookkeeping (#9430 follow-up)
// -----------------------------------------------------------------------
// The VarUnit's savedVars/hiddenByUser sets are name-keyed and independent
// of the lua_State (VarUnit::clear() deliberately preserves them), and
// nothing repopulates them after a reset - only XMLimport at profile open
// does. If replacing the LuaInterface in resetProfile_phase2() loses them,
// the first profile save after a reset silently drops every user-saved
// variable and hidden-variable preference from the profile XML.
void test_savedAndHiddenVarSetsSurviveReset() {
lua_State *L = mpHost->mLuaInterpreter.getLuaGlobalState();
luaL_dostring(L, "resetSavedTestVar = 'important'");
LuaInterface *lI = mpHost->getLuaInterface();
VarUnit *vu = lI->getVarUnit();
lI->getVars(false);
TVar *var = findGlobalVar(vu, qsl("resetSavedTestVar"));
QVERIFY2(var, "test variable not found in the variable tree");
// as the Variables view does when the user ticks the save checkbox:
vu->addSavedVar(var);
// and as it does when the user hides a variable:
vu->addHidden(qsl("resetHiddenTestVar"));
QVERIFY(vu->savedVars.contains(qsl("resetSavedTestVar")));
QVERIFY(vu->hiddenByUser.contains(qsl("resetHiddenTestVar")));
performReset();
VarUnit *newVu = mpHost->getLuaInterface()->getVarUnit();
QVERIFY2(newVu->savedVars.contains(qsl("resetSavedTestVar")),
"user's saved-variable marking should survive resetProfile()");
QVERIFY2(newVu->hiddenByUser.contains(qsl("resetHiddenTestVar")),
"user's hidden-variable preference should survive resetProfile()");
}
// End-to-end version of the above: the saved variable must still be
// written out to profile XML after a reset.
void test_savedVariableExportedToXmlAfterReset() {
lua_State *L = mpHost->mLuaInterpreter.getLuaGlobalState();
luaL_dostring(L, "xmlSavedTestVar = 'survives'");
LuaInterface *lI = mpHost->getLuaInterface();
VarUnit *vu = lI->getVarUnit();
lI->getVars(false);
TVar *var = findGlobalVar(vu, qsl("xmlSavedTestVar"));
QVERIFY2(var, "test variable not found in the variable tree");
vu->addSavedVar(var);
performReset();
// the reset wiped the Lua value; user scripts recreate it on
// sysLoadEvent, and opening the Variables view rebuilds the tree
lua_State *newL = mpHost->mLuaInterpreter.getLuaGlobalState();
luaL_dostring(newL, "xmlSavedTestVar = 'survives'");
mpHost->getLuaInterface()->getVars(false);
const QString xmlPath =
mudlet::getMudletPath(enums::profileHomePath, mHostname) +
qsl("/reset-var-test.xml");
auto writer = std::make_shared<XMLexport>(mpHost);
QVERIFY(writer->exportPackage(xmlPath, true, false));
QFile file(xmlPath);
QVERIFY(file.open(QIODevice::ReadOnly | QIODevice::Text));
const QString xml = QString::fromUtf8(file.readAll());
file.close();
QFile::remove(xmlPath);
QVERIFY2(xml.contains(qsl("xmlSavedTestVar")),
"saved variable should still be exported to profile XML after "
"a reset");
}
// A fresh profile load runs hideMudletsVariables() right after
// loadGlobal() so the Variables view only shows the user's variables;
// a reset must do the same or the view fills up with Mudlet's entire
// internal Lua API.
void test_mudletInternalVariablesHiddenAfterReset() {
mpHost->hideMudletsVariables();
VarUnit *vu = mpHost->getLuaInterface()->getVarUnit();
QVERIFY(vu->hidden.contains(qsl("color_table")));
QVERIFY(vu->hidden.contains(qsl("Geyser")));
performReset();
VarUnit *newVu = mpHost->getLuaInterface()->getVarUnit();
QVERIFY2(newVu->hidden.contains(qsl("color_table")),
"Mudlet's internal variables should be re-hidden after reset, "
"as on profile load");
QVERIFY2(newVu->hidden.contains(qsl("Geyser")),
"Mudlet's internal variables should be re-hidden after reset, "
"as on profile load");
}
// -----------------------------------------------------------------------
// Helpers (reused from TOscTerminatorTest pattern)
// -----------------------------------------------------------------------
TVar *findGlobalVar(VarUnit *vu, const QString &name) {
for (auto *child : vu->getBase()->getChildren(false)) {
if (child->getName() == name) {
return child;
}
}
return nullptr;
}
void startProfile(const QString &hostname, const QString &address,
const QString &port) {
QTimer::singleShot(0ms, qApp, [hostname, address, port]() {
mudlet::self()->startAutoLogin({});
QTest::qWait(100ms);
QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button,
Qt::LeftButton);
QTest::qWait(100ms);
QTest::keyClicks(QApplication::focusWidget(), hostname);
QTest::qWait(100ms);
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab);
QTest::qWait(100ms);
QTest::keyClicks(QApplication::focusWidget(), address);
QTest::qWait(100ms);
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab);
QTest::qWait(100ms);
QTest::keyClicks(QApplication::focusWidget(), port);
QTest::qWait(100ms);
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Return);
});
QSignalSpy spy(mudlet::self(), &mudlet::signal_profileLoaded);
if (!spy.wait(1000)) {
QFAIL("Profile took too long to load.");
}
auto host = mudlet::self()->getActiveHost();
if (!host) {
QFAIL("No active host available for the test.");
}
QSignalSpy spy2(&(host->mTelnet), &cTelnet::signal_connected);
if (!spy2.wait(500)) {
QFAIL("Could not connect with the host.");
}
}
void deleteProfileDirectory(const QString &profileName) {
const QString path =
mudlet::getMudletPath(enums::profileHomePath, profileName);
QDir dir(path);
if (!dir.exists()) {
return;
}
dir.removeRecursively();
}
};
void initializeQRCResourcesForResetProfileTest() {
#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 "ResetProfileTest.moc"
QTEST_MAIN(ResetProfileTest)