mudlet/test/functional_tests/XMLexportVariablesTest.cpp
Vadim Peretokin f8eb6b5597
fix: saved variables holding functions come back empty again instead of half-filled (#9860)
#### Brief overview of PR changes/additions

- A saved variable with a function, userdata or coroutine anywhere
inside it now exports only the members registered in `savedVars`, which
for a table ticked while empty is an empty group. Tables holding nothing
but data keep the full save added in #9762.
- The save-time walk records which saved globals hold such a value;
`XMLexport` turns the ride-along off for those variables only. Silent,
and it costs nothing outside a save.
- New `SavedVariableFenceTest` pins all 11 measured shapes, on disk and
after a reload.

#### Motivation for adding to Mudlet

A half-restored table defeats the `if next(t) == nil then rebuild() end`
guard packages carry, which kills cron-daemon's scheduler permanently on
its first minute wake after a restart.

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

Fixes #9857

**Test case:** install cron-daemon, tick `cron` in the Variables view,
add a job with a `command` function, restart and wait for the minute
rollover - the daemon keeps running (measured 2/2 canary fires, against
0/2 before).

Assisted-by: Claude:claude-opus-5
Signed-off-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
2026-08-13 15:49:17 +02:00

947 lines
47 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. *
***************************************************************************/
/*
* Tests for XMLexport::writeVariablePackage(): variables created after the
* VarUnit tree was last built (e.g. by scripts at runtime) must still be
* written to the profile XML when they are marked as saved. The tree is
* only (re)built at profile load and when the Variables view is populated,
* so without a refresh at export time such variables silently vanish from
* profile saves. Also covers members a script adds to a saved table at
* runtime: they have no savedVars entry of their own but must be saved with
* the table (issue #9517), while hidden and unsaveable members must not be.
* Also covers a saved table that other globals reference, which must export in
* full under every saved name that reaches it (issue #9755), and the boundary of
* the fence that stops a table holding a function exporting in part (issue
* #9857) - SavedVariableFenceTest covers that one in full.
*
* Run with: ctest -R XMLexportVariablesTest -V
*/
#include <QtTest/QtTest>
#include "Host.h"
#include "LuaInterface.h"
#include "MudletInstanceCoordinator.h"
#include "TelnetServerStub.h"
#include "VarUnit.h"
#include "XMLexport.h"
#include "XMLimport.h"
#include "ctelnet.h"
#include "dlgConnectionProfiles.h"
#include "dlgTriggerEditor.h"
#include "mudlet.h"
#include <QRegularExpression>
#include <QTreeWidget>
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
}
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 initializeQRCResourcesForXMLexportVariablesTest();
class XMLexportVariablesTest : public QObject
{
Q_OBJECT
private:
TelnetServerStub* mpServer = nullptr;
Host* mpHost = nullptr;
dlgTriggerEditor* mpEditor = nullptr;
const QString mHostname = "XMLexportVars-Test";
const QString mLocalhost = "localhost";
private slots:
void initTestCase()
{
initializeQRCResourcesForXMLexportVariablesTest();
mpServer = new TelnetServerStub(qApp);
// port 0 asks the OS for an ephemeral port, so parallel test runs
// (and other worktrees) cannot collide on a fixed one
mpServer->start(mLocalhost, 0);
QVERIFY2(mpServer->serverPort() != 0, "TelnetServerStub failed to bind a loopback port");
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, QString::number(mpServer->serverPort()));
mpHost = mudlet::self()->getActiveHost();
QVERIFY2(mpHost, "No active host after profile creation");
}
void cleanupTestCase()
{
mpEditor = nullptr;
mpHost = nullptr;
delete mpServer;
mpServer = nullptr;
deleteProfileDirectory(mHostname);
delete mudlet::self();
}
// A saved variable whose Lua value only comes into existence after the
// variable tree was last built (profile load, Variables view opening)
// must still be written out - the save path has to refresh the tree.
void test_lateCreatedSavedVariableIsExported()
{
// QTest runs slots in declaration order and these stand for a profile
// whose Variables view was never opened. Profile load builds the editor
// dialog itself, so what matters is that no slot has shown it yet.
QVERIFY2(!mpEditor, "a Variables-view test was declared before the ones that must run without it");
LuaInterface* lI = mpHost->getLuaInterface();
VarUnit* vu = lI->getVarUnit();
// build the tree directly, standing in for the initial build that
// profile load performs (via Host::hideMudletsVariables())
lI->getVars(false);
QVERIFY(vu->getBase());
// a script creates the variable after that; we mark its name as saved
// to emulate a variable persisted in a previous session (savedVars is
// name-keyed and persistent, so it survives a tree rebuild)
lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState();
QCOMPARE(luaL_dostring(L, "lateSavedTestVar = 'created after tree build'"), 0);
vu->savedVars.insert(qsl("lateSavedTestVar"));
const QString xml = exportProfileXml();
QVERIFY(!xml.isEmpty());
QVERIFY2(xml.contains(qsl("lateSavedTestVar")),
"saved variable created after the last variable-tree build should "
"still be exported to the profile XML");
// the value is the payload of the save - make sure it is written, not
// just an empty node with the right name
QVERIFY2(xml.contains(qsl("created after tree build")), "the saved variable's value must be exported, not just its name");
// mpHost is shared across the tests, so undo the state this one added
vu->savedVars.remove(qsl("lateSavedTestVar"));
QCOMPARE(luaL_dostring(L, "lateSavedTestVar = nil"), 0);
}
// The export-time refresh must not start saving variables that are not
// marked as saved.
void test_lateUnsavedVariableIsNotExported()
{
LuaInterface* lI = mpHost->getLuaInterface();
lI->getVars(false);
lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState();
QCOMPARE(luaL_dostring(L, "lateUnsavedTestVar = 'not marked saved'"), 0);
const QString xml = exportProfileXml();
QVERIFY(!xml.isEmpty());
QVERIFY2(!xml.contains(qsl("lateUnsavedTestVar")), "a variable not marked as saved must not be exported");
}
// A member a script adds to a saved table at runtime has no savedVars
// entry of its own, but must still be saved with the table (issue #9517).
void test_runtimeAddedMemberOfSavedTableIsExported()
{
LuaInterface* lI = mpHost->getLuaInterface();
VarUnit* vu = lI->getVarUnit();
lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState();
QCOMPARE(luaL_dostring(L, "memberTestTable = {existing = 'existing member value'}"), 0);
// ticking a table in the Variables view registers the table and the
// members that exist at that moment
vu->savedVars.insert(qsl("memberTestTable"));
vu->savedVars.insert(qsl("memberTestTable.existing"));
lI->getVars(false);
// a script adds another member after that
QCOMPARE(luaL_dostring(L, "memberTestTable.newcomer = 'runtime member value'"), 0);
const QString xml = exportProfileXml();
QVERIFY(!xml.isEmpty());
QVERIFY2(xml.contains(qsl("existing member value")), "member registered when the table was ticked must still be exported");
QVERIFY2(xml.contains(qsl("runtime member value")), "member added to a saved table at runtime must be saved with the table");
vu->savedVars.remove(qsl("memberTestTable"));
vu->savedVars.remove(qsl("memberTestTable.existing"));
QCOMPARE(luaL_dostring(L, "memberTestTable = nil"), 0);
}
// A nested table assigned into a saved table at runtime must be exported
// recursively, right down to its innermost members.
void test_nestedTableAddedToSavedTableIsExported()
{
LuaInterface* lI = mpHost->getLuaInterface();
VarUnit* vu = lI->getVarUnit();
lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState();
QCOMPARE(luaL_dostring(L, "nestedTestTable = {}"), 0);
vu->savedVars.insert(qsl("nestedTestTable"));
lI->getVars(false);
QCOMPARE(luaL_dostring(L, "nestedTestTable.inner = {deepest = 'nested member value'}"), 0);
const QString xml = exportProfileXml();
QVERIFY(!xml.isEmpty());
QVERIFY2(xml.contains(qsl("nested member value")), "members of a nested table added to a saved table at runtime must be exported");
vu->savedVars.remove(qsl("nestedTestTable"));
QCOMPARE(luaL_dostring(L, "nestedTestTable = nil"), 0);
}
// The most common shape of issue #9517: a list-style table grown with
// table.insert at runtime. The numeric key must keep its key type so
// import restores t[1] and not t["1"].
void test_numericKeyMemberAddedAtRuntimeIsExported()
{
LuaInterface* lI = mpHost->getLuaInterface();
VarUnit* vu = lI->getVarUnit();
lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState();
QCOMPARE(luaL_dostring(L, "numericListTable = {}"), 0);
vu->savedVars.insert(qsl("numericListTable"));
lI->getVars(false);
QCOMPARE(luaL_dostring(L, "table.insert(numericListTable, 'numeric member value')"), 0);
const QString xml = exportProfileXml();
QVERIFY(!xml.isEmpty());
QVERIFY2(xml.contains(qsl("numeric member value")), "a numeric-keyed member added at runtime must be saved with its table");
// LUA_TNUMBER == 3: the key type decides whether import restores t[1] or t["1"]
QVERIFY2(xml.contains(qsl("<keyType>3</keyType>")), "the numeric member's key type must be numeric so import restores t[1], not t['1']");
vu->savedVars.remove(qsl("numericListTable"));
QCOMPARE(luaL_dostring(L, "numericListTable = nil"), 0);
}
// Design pin: un-ticking a single member in the Variables view only
// removes its name from savedVars, which cannot be told apart from a
// member added after the table was ticked. A saved table therefore
// exports its members as they exist at save time; to keep a member out
// of the profile, hide it, remove it, or stop saving the table.
void test_untickedMemberOfSavedTableStillExports()
{
LuaInterface* lI = mpHost->getLuaInterface();
VarUnit* vu = lI->getVarUnit();
lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState();
QCOMPARE(luaL_dostring(L, "untickedMemberTable = {kept = 'kept member value', unticked = 'unticked member value'}"), 0);
// ticking the table registers it and both members...
vu->savedVars.insert(qsl("untickedMemberTable"));
vu->savedVars.insert(qsl("untickedMemberTable.kept"));
vu->savedVars.insert(qsl("untickedMemberTable.unticked"));
// ...and un-ticking one member only removes its name again
vu->savedVars.remove(qsl("untickedMemberTable.unticked"));
lI->getVars(false);
const QString xml = exportProfileXml();
QVERIFY(!xml.isEmpty());
QVERIFY2(xml.contains(qsl("kept member value")), "a ticked member of a saved table must be exported");
QVERIFY2(xml.contains(qsl("unticked member value")), "a saved table exports members as they exist at save time, so an un-ticked member rides along");
vu->savedVars.remove(qsl("untickedMemberTable"));
vu->savedVars.remove(qsl("untickedMemberTable.kept"));
QCOMPARE(luaL_dostring(L, "untickedMemberTable = nil"), 0);
}
// A member table beyond the 10,000-item save limit must not ride along -
// it would bloat every profile save.
void test_oversizedMemberTableIsNotExported()
{
LuaInterface* lI = mpHost->getLuaInterface();
VarUnit* vu = lI->getVarUnit();
lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState();
QCOMPARE(luaL_dostring(L,
"oversizedHolderTable = {smallMember = 'small member value', bigMember = {}} "
"for i = 1, 10001 do oversizedHolderTable.bigMember[i] = 'oversized member value' end"),
0);
vu->savedVars.insert(qsl("oversizedHolderTable"));
lI->getVars(false);
const QString xml = exportProfileXml();
QVERIFY(!xml.isEmpty());
QVERIFY2(xml.contains(qsl("small member value")), "a plain member of a saved table must be exported");
QVERIFY2(!xml.contains(qsl("oversized member value")), "a member table over the 10,000-item limit must not ride along with its saved table");
vu->savedVars.remove(qsl("oversizedHolderTable"));
QCOMPARE(luaL_dostring(L, "oversizedHolderTable = nil"), 0);
}
// A member whose key is a reference (e.g. a table used as a key) cannot
// be restored from XML and must not ride along.
void test_referenceKeyMemberIsNotExported()
{
LuaInterface* lI = mpHost->getLuaInterface();
VarUnit* vu = lI->getVarUnit();
lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState();
QCOMPARE(luaL_dostring(L, "referenceKeyTable = {plainMember = 'plain member value'} referenceKeyTable[{}] = 'reference member value'"), 0);
vu->savedVars.insert(qsl("referenceKeyTable"));
lI->getVars(false);
const QString xml = exportProfileXml();
QVERIFY(!xml.isEmpty());
QVERIFY2(xml.contains(qsl("plain member value")), "a plain member of a saved table must be exported");
QVERIFY2(!xml.contains(qsl("reference member value")), "a reference-keyed member must not ride along with its saved table");
vu->savedVars.remove(qsl("referenceKeyTable"));
QCOMPARE(luaL_dostring(L, "referenceKeyTable = nil"), 0);
}
// Members only ride along with tables that are marked saved.
void test_memberOfUnsavedTableIsNotExported()
{
lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState();
QCOMPARE(luaL_dostring(L, "unsavedTestTable = {member = 'unsaved member value'}"), 0);
const QString xml = exportProfileXml();
QVERIFY(!xml.isEmpty());
QVERIFY2(!xml.contains(qsl("unsavedTestTable")), "a table not marked as saved must not be exported");
QVERIFY2(!xml.contains(qsl("unsaved member value")), "members of a table not marked as saved must not be exported");
QCOMPARE(luaL_dostring(L, "unsavedTestTable = nil"), 0);
}
// Hidden variables (Mudlet's internals, or ones the user hid) inside a
// saved table keep needing their own explicit save mark, so internals
// cannot leak into the profile XML through a saved parent.
void test_hiddenMemberOfSavedTableIsNotExported()
{
LuaInterface* lI = mpHost->getLuaInterface();
VarUnit* vu = lI->getVarUnit();
lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState();
QCOMPARE(luaL_dostring(L, "hiddenMemberTable = {visibleMember = 'visible member value', secretMember = 'secret member value'}"), 0);
vu->savedVars.insert(qsl("hiddenMemberTable"));
vu->addHidden(qsl("hiddenMemberTable.secretMember"));
lI->getVars(false);
const QString xml = exportProfileXml();
QVERIFY(!xml.isEmpty());
QVERIFY2(xml.contains(qsl("visible member value")), "a plain member of a saved table must be exported");
QVERIFY2(!xml.contains(qsl("secret member value")), "a hidden member must not ride along with its saved table");
vu->savedVars.remove(qsl("hiddenMemberTable"));
vu->removeHidden(qsl("hiddenMemberTable.secretMember"));
QCOMPARE(luaL_dostring(L, "hiddenMemberTable = nil"), 0);
}
// A hidden member the user explicitly ticked stays exported - hiding only
// blocks the ride-along, not an explicit save mark.
void test_explicitlySavedHiddenMemberIsExported()
{
LuaInterface* lI = mpHost->getLuaInterface();
VarUnit* vu = lI->getVarUnit();
lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState();
QCOMPARE(luaL_dostring(L, "explicitHiddenTable = {pinnedMember = 'pinned member value'}"), 0);
vu->savedVars.insert(qsl("explicitHiddenTable"));
vu->savedVars.insert(qsl("explicitHiddenTable.pinnedMember"));
vu->addHidden(qsl("explicitHiddenTable.pinnedMember"));
lI->getVars(false);
const QString xml = exportProfileXml();
QVERIFY(!xml.isEmpty());
QVERIFY2(xml.contains(qsl("pinned member value")), "a hidden member explicitly marked as saved must still be exported");
vu->savedVars.remove(qsl("explicitHiddenTable"));
vu->savedVars.remove(qsl("explicitHiddenTable.pinnedMember"));
vu->removeHidden(qsl("explicitHiddenTable.pinnedMember"));
QCOMPARE(luaL_dostring(L, "explicitHiddenTable = nil"), 0);
}
// Function members cannot be saved, and a table holding one therefore cannot
// be written out as it stands: the ride-along is off for the whole variable,
// which for a table ticked while empty leaves an empty group (#9857).
// SavedVariableFenceTest covers that in full.
void test_functionMemberBlocksTheRideAlong()
{
LuaInterface* lI = mpHost->getLuaInterface();
VarUnit* vu = lI->getVarUnit();
lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState();
QCOMPARE(luaL_dostring(L, "callableHolderTable = {dataMember = 'data member value', callableMember = function() end}"), 0);
vu->savedVars.insert(qsl("callableHolderTable"));
lI->getVars(false);
const QString xml = exportProfileXml();
QVERIFY(!xml.isEmpty());
QVERIFY2(xml.contains(qsl("<name>callableHolderTable</name>")), "the saved table itself must still be exported, or the next session sees nil rather than an empty table");
QVERIFY2(!xml.contains(qsl("dataMember")), "a table holding a function exports the members registered in savedVars and no others");
QVERIFY2(!xml.contains(qsl("callableMember")), "a function member must not ride along with its saved table");
vu->savedVars.remove(qsl("callableHolderTable"));
QCOMPARE(luaL_dostring(L, "callableHolderTable = nil"), 0);
}
// The export-time refresh must keep writing the user's hidden-variable
// preferences to the HiddenVariables node.
void test_hiddenPreferenceStillExported()
{
VarUnit* vu = mpHost->getLuaInterface()->getVarUnit();
vu->addHidden(qsl("userHiddenPrefVar"));
const QString xml = exportProfileXml();
QVERIFY(!xml.isEmpty());
QVERIFY2(xml.contains(qsl("userHiddenPrefVar")), "hiddenByUser names must still be written to HiddenVariables");
vu->removeHidden(qsl("userHiddenPrefVar"));
}
// VarUnit has two hidden sets: hiddenByUser, and hidden, which
// Host::hideMudletsVariables() fills with Mudlet's own Lua API. Both have to
// reach the export's tree or a saved table drags the internals into the XML.
void test_internallyHiddenMemberOfSavedTableIsNotExported()
{
LuaInterface* lI = mpHost->getLuaInterface();
VarUnit* vu = lI->getVarUnit();
lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState();
QCOMPARE(luaL_dostring(L, "internalHiddenTable = {plainMember = 'plain member value', internalMember = 'internal member value'}"), 0);
vu->savedVars.insert(qsl("internalHiddenTable"));
// what addHidden(TVar*, 0) records - the non-user half of the pair
vu->hidden.insert(qsl("internalHiddenTable.internalMember"));
const QString xml = exportProfileXml();
QVERIFY(!xml.isEmpty());
QVERIFY2(xml.contains(qsl("plain member value")), "a plain member of a saved table must be exported");
QVERIFY2(!xml.contains(qsl("internal member value")), "a member hidden by Mudlet itself must not ride along with its saved table");
vu->savedVars.remove(qsl("internalHiddenTable"));
vu->hidden.remove(qsl("internalHiddenTable.internalMember"));
QCOMPARE(luaL_dostring(L, "internalHiddenTable = nil"), 0);
}
// A variable tree takes a Lua registry reference per reference-keyed entry.
// The export throws its tree away, so if the references went with it the
// registry would grow by that many slots on every save.
void test_exportDoesNotLeakLuaRegistryReferences()
{
VarUnit* vu = mpHost->getLuaInterface()->getVarUnit();
lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState();
// several reference-keyed members, so a leak grows the registry visibly.
// The table has to be saved for the export to read it at all, and each
// of those keys costs a registry reference while it does
QCOMPARE(luaL_dostring(L, "refKeyLeakTable = {} for i = 1, 20 do refKeyLeakTable[{}] = i end"), 0);
vu->savedVars.insert(qsl("refKeyLeakTable"));
// freed slots go on a free list and come straight back out, so the
// number stops climbing once the registry fits one pass's worth.
// Measuring after the first export leaves that one-off growth out.
QVERIFY(!exportProfileXml().isEmpty());
lua_pushboolean(L, 1);
const int refAfterOne = luaL_ref(L, LUA_REGISTRYINDEX);
luaL_unref(L, LUA_REGISTRYINDEX, refAfterOne);
for (int i = 0; i < 5; ++i) {
QVERIFY(!exportProfileXml().isEmpty());
}
lua_pushboolean(L, 1);
const int refAfterSix = luaL_ref(L, LUA_REGISTRYINDEX);
luaL_unref(L, LUA_REGISTRYINDEX, refAfterSix);
// five more exports keeping 20 references each would put this 100 higher
QVERIFY2(refAfterSix < refAfterOne + 20,
qPrintable(qsl("the exports pinned Lua registry slots: a reference taken after one export was %1, one taken after six was %2").arg(refAfterOne).arg(refAfterSix)));
vu->savedVars.remove(qsl("refKeyLeakTable"));
QCOMPARE(luaL_dostring(L, "refKeyLeakTable = nil"), 0);
}
// A saved table other globals also reference exports in full, whichever of
// the names anything else happens to reach it by (issue #9755).
void test_savedTableReachedByAnotherGlobalIsExported()
{
LuaInterface* lI = mpHost->getLuaInterface();
VarUnit* vu = lI->getVarUnit();
lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState();
QCOMPARE(luaL_dostring(L, "aliasedSavedTable = {member = 'aliased member value'}"), 0);
vu->savedVars.insert(qsl("aliasedSavedTable"));
vu->savedVars.insert(qsl("aliasedSavedTable.member"));
// seven aliases, not one: should the export ever go back to walking all
// of _G, hash order picks which name wins, and one alias would then only
// fail this test some of the time
QCOMPARE(luaL_dostring(L,
"aaaAliasOfSaved = aliasedSavedTable bAliasOfSaved = aliasedSavedTable m1AliasOfSaved = aliasedSavedTable "
"xyzzyAliasOfSaved = aliasedSavedTable alphaAliasOfSaved = aliasedSavedTable ref1AliasOfSaved = aliasedSavedTable "
"A_1AliasOfSaved = aliasedSavedTable"),
0);
const QString xml = exportProfileXml();
QVERIFY(!xml.isEmpty());
QVERIFY2(xml.contains(qsl("aliasedSavedTable")), "a saved table must be exported however many other globals also reference it");
QVERIFY2(xml.contains(qsl("aliased member value")), "the members of a saved table other globals reference must be exported too");
QVERIFY2(!xml.contains(qsl("AliasOfSaved")), "the other globals are not saved themselves, so they must not be exported");
vu->savedVars.remove(qsl("aliasedSavedTable"));
vu->savedVars.remove(qsl("aliasedSavedTable.member"));
QCOMPARE(luaL_dostring(L, "aliasedSavedTable, aaaAliasOfSaved, bAliasOfSaved, m1AliasOfSaved, xyzzyAliasOfSaved, alphaAliasOfSaved, ref1AliasOfSaved, A_1AliasOfSaved = nil"), 0);
}
// Two saved globals that are the same table. Neither may be reduced to an
// empty group by the other having been read first.
void test_twoSavedGlobalsSharingATableBothExport()
{
LuaInterface* lI = mpHost->getLuaInterface();
VarUnit* vu = lI->getVarUnit();
lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState();
QCOMPARE(luaL_dostring(L, "sharedFirstTable = {member = 'shared member value'} sharedSecondTable = sharedFirstTable"), 0);
for (const auto& name : {qsl("sharedFirstTable"), qsl("sharedFirstTable.member"), qsl("sharedSecondTable"), qsl("sharedSecondTable.member")}) {
vu->savedVars.insert(name);
}
const QString xml = exportProfileXml();
QVERIFY(!xml.isEmpty());
QVERIFY2(xml.contains(qsl("sharedFirstTable")), "the first of two saved globals sharing a table must be exported");
QVERIFY2(xml.contains(qsl("sharedSecondTable")), "the second of two saved globals sharing a table must be exported");
QCOMPARE(xml.count(qsl("shared member value")), 2);
for (const auto& name : {qsl("sharedFirstTable"), qsl("sharedFirstTable.member"), qsl("sharedSecondTable"), qsl("sharedSecondTable.member")}) {
vu->savedVars.remove(name);
}
QCOMPARE(luaL_dostring(L, "sharedFirstTable, sharedSecondTable = nil"), 0);
}
// The shape a stock profile hits without anyone aliasing anything: a saved
// table that a second saved table holds as a member, which is how the EMCO
// and AdjustableContainer packages reach each other's tables.
void test_savedTableHeldByAnotherSavedTableIsExported()
{
LuaInterface* lI = mpHost->getLuaInterface();
VarUnit* vu = lI->getVarUnit();
lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState();
QCOMPARE(luaL_dostring(L, "innerSavedTable = {member = 'inner member value'} holderSavedTable = {inner = innerSavedTable}"), 0);
for (const auto& name : {qsl("innerSavedTable"), qsl("innerSavedTable.member"), qsl("holderSavedTable"), qsl("holderSavedTable.inner")}) {
vu->savedVars.insert(name);
}
const QString xml = exportProfileXml();
QVERIFY(!xml.isEmpty());
QVERIFY2(xml.contains(qsl("innerSavedTable")), "a saved table another saved table holds must still be exported in its own right");
QCOMPARE(xml.count(qsl("inner member value")), 2);
for (const auto& name : {qsl("innerSavedTable"), qsl("innerSavedTable.member"), qsl("holderSavedTable"), qsl("holderSavedTable.inner")}) {
vu->savedVars.remove(name);
}
QCOMPARE(luaL_dostring(L, "innerSavedTable, holderSavedTable = nil"), 0);
}
// Design pin. Two members of one saved table that are the same Lua table
// are not both exported: the profile XML has no way to say "these two names
// are one table", so the export would have to write the subtree once per
// name. On a profile whose saved table holds a UI object that multiplies
// out - measured at 5.5x the file, and big enough for the 10,000-item limit
// to then drop whole branches - so the walk keeps one copy per saved name
// and no more. Members the user ticks individually are exported in full.
void test_twoMembersOfASavedTableSharingATableExportOnce()
{
LuaInterface* lI = mpHost->getLuaInterface();
VarUnit* vu = lI->getVarUnit();
lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState();
QCOMPARE(luaL_dostring(L, "memberShareTable = {} memberShareTable.first = {member = 'member share value'} memberShareTable.second = memberShareTable.first"), 0);
vu->savedVars.insert(qsl("memberShareTable"));
QString xml = exportProfileXml();
QVERIFY(!xml.isEmpty());
QCOMPARE(xml.count(qsl("member share value")), 1);
// ticking a member in the Variables view (which is what savedVars holds)
// is what asks for it to be written under its own name as well
vu->savedVars.insert(qsl("memberShareTable.first"));
vu->savedVars.insert(qsl("memberShareTable.second"));
xml = exportProfileXml();
QCOMPARE(xml.count(qsl("member share value")), 2);
for (const auto& name : {qsl("memberShareTable"), qsl("memberShareTable.first"), qsl("memberShareTable.second")}) {
vu->savedVars.remove(name);
}
QCOMPARE(luaL_dostring(L, "memberShareTable = nil"), 0);
}
// A table that holds itself must not send the walk round for ever.
void test_selfReferencingSavedTableIsExported()
{
LuaInterface* lI = mpHost->getLuaInterface();
VarUnit* vu = lI->getVarUnit();
lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState();
QCOMPARE(luaL_dostring(L, "cyclicSavedTable = {member = 'cyclic member value'} cyclicSavedTable.self = cyclicSavedTable"), 0);
vu->savedVars.insert(qsl("cyclicSavedTable"));
vu->savedVars.insert(qsl("cyclicSavedTable.self"));
const QString xml = exportProfileXml();
QVERIFY(!xml.isEmpty());
QVERIFY2(xml.contains(qsl("cyclic member value")), "a table that references itself must still export its other members");
vu->savedVars.remove(qsl("cyclicSavedTable"));
vu->savedVars.remove(qsl("cyclicSavedTable.self"));
QCOMPARE(luaL_dostring(L, "cyclicSavedTable = nil"), 0);
}
// What keeps a save off the size of _G: the tree it builds holds the saved
// globals and nothing else, so a global a profile does not save costs a key
// conversion and a hash lookup instead of a walk of everything it reaches.
void test_saveTimeTreeHoldsOnlyTheSavedGlobals()
{
lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState();
QCOMPARE(luaL_dostring(L,
"for i = 1, 500 do _G['walkNoiseTable' .. i] = {nested = {deeper = i}} end "
"walkSavedRootTable = {member = 'walk member value'}"),
0);
LuaInterface saveTimeInterface(L);
saveTimeInterface.getVarUnit()->savedVars.insert(qsl("walkSavedRootTable"));
saveTimeInterface.getSavedVars();
TVar* pBase = saveTimeInterface.getVarUnit()->getBase();
QVERIFY(pBase);
const QList<TVar*> roots = pBase->getChildren(false);
QCOMPARE(roots.size(), 1);
QCOMPARE(roots.constFirst()->getName(), qsl("walkSavedRootTable"));
QCOMPARE(roots.constFirst()->getChildren(false).size(), 1);
// the saved-globals-only mode must not outlive the call that asked for
// it, or the Variables view built from this interface would show almost
// nothing
saveTimeInterface.getVars(false);
QVERIFY2(saveTimeInterface.getVarUnit()->getBase()->getChildren(false).size() > 1, "a getVars() after getSavedVars() must go back to reading the whole of _G");
saveTimeInterface.releaseVariableReferences();
QCOMPARE(luaL_dostring(L, "for i = 1, 500 do _G['walkNoiseTable' .. i] = nil end walkSavedRootTable = nil"), 0);
}
// The export borrows the profile's live Lua state, so anything it leaves on
// the stack is charged to every trigger, alias and timer for the rest of the
// session. Covers the three ways out of the walk: nothing saved at all, a
// normal walk, and one cut short by the nesting limit.
void test_exportLeavesTheLuaStackAsItFoundIt()
{
LuaInterface* lI = mpHost->getLuaInterface();
VarUnit* vu = lI->getVarUnit();
lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState();
const QSet<QString> savedVarsBefore = vu->savedVars;
vu->savedVars.clear();
const int stackWithNothingSaved = lua_gettop(L);
const QString emptyXml = exportProfileXml();
QVERIFY(!emptyXml.isEmpty());
QVERIFY2(!emptyXml.contains(qsl("<Variable>")), "with nothing marked as saved the VariablePackage must come out empty");
QCOMPARE(lua_gettop(L), stackWithNothingSaved);
vu->savedVars = savedVarsBefore;
QCOMPARE(luaL_dostring(L,
"stackCheckTable = {member = 'stack check value', nested = {deeper = 'deeper stack value'}} "
"local t = stackCheckTable "
"for i = 1, 120 do t.nested = {} t = t.nested end"),
0);
vu->savedVars.insert(qsl("stackCheckTable"));
const int stackBefore = lua_gettop(L);
QTest::ignoreMessage(QtWarningMsg, QRegularExpression(qsl("nested more than 99 tables deep")));
QVERIFY(!exportProfileXml().isEmpty());
QCOMPARE(lua_gettop(L), stackBefore);
vu->savedVars.remove(qsl("stackCheckTable"));
QCOMPARE(luaL_dostring(L, "stackCheckTable = nil"), 0);
}
// A table-keyed entry costs a Lua registry reference to name at all, and the
// walk takes that reference before it knows whether the global is saved. The
// ones it then skips still have to be handed back.
void test_skippedGlobalsDoNotPinLuaRegistryReferences()
{
lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState();
QCOMPARE(luaL_dostring(L, "for i = 1, 20 do _G[{}] = 'unsaved table-keyed global' end"), 0);
QVERIFY(!exportProfileXml().isEmpty());
lua_pushboolean(L, 1);
const int refAfterOne = luaL_ref(L, LUA_REGISTRYINDEX);
luaL_unref(L, LUA_REGISTRYINDEX, refAfterOne);
for (int i = 0; i < 5; ++i) {
QVERIFY(!exportProfileXml().isEmpty());
}
lua_pushboolean(L, 1);
const int refAfterSix = luaL_ref(L, LUA_REGISTRYINDEX);
luaL_unref(L, LUA_REGISTRYINDEX, refAfterSix);
QVERIFY2(refAfterSix < refAfterOne + 20,
qPrintable(qsl("the exports pinned Lua registry slots for globals they skipped: a reference taken after one export was %1, one taken after six was %2")
.arg(refAfterOne)
.arg(refAfterSix)));
QCOMPARE(luaL_dostring(L,
"local deadKeys = {} for k in pairs(_G) do if type(k) == 'table' then deadKeys[#deadKeys + 1] = k end end "
"for _, k in ipairs(deadKeys) do _G[k] = nil end"),
0);
}
// The walk stops at 99 levels of nesting and hands back an empty table,
// which for a saved variable means its contents are not in the save. That
// has to be said out loud rather than left to be discovered.
void test_tableNestedPastTheWalkLimitIsReportedNotSilentlyEmptied()
{
LuaInterface* lI = mpHost->getLuaInterface();
VarUnit* vu = lI->getVarUnit();
lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState();
QCOMPARE(luaL_dostring(L,
"deeplyNestedSavedTable = {shallow = 'shallow member value'} "
"local t = deeplyNestedSavedTable "
"for i = 1, 120 do t.nested = {} t = t.nested end "
"t.deepest = 'past the limit value'"),
0);
vu->savedVars.insert(qsl("deeplyNestedSavedTable"));
QTest::ignoreMessage(QtWarningMsg, QRegularExpression(qsl("nested more than 99 tables deep")));
const QString xml = exportProfileXml();
QVERIFY(!xml.isEmpty());
QVERIFY2(xml.contains(qsl("shallow member value")), "the members the walk did reach must still be exported");
QVERIFY2(!xml.contains(qsl("past the limit value")), "a member past the nesting limit is not exported - the warning above is what tells the user");
vu->savedVars.remove(qsl("deeplyNestedSavedTable"));
QCOMPARE(luaL_dostring(L, "deeplyNestedSavedTable = nil"), 0);
}
// A script adds to a saved table while the editor sits on the Variables
// view. A session's last save is taken with whatever view was left on
// screen, so quitting from there is enough to reach this.
void test_savedTableMemberIsExportedWithVariablesViewOpen()
{
QVERIFY2(showEditorOnVariablesView(), "the script editor could not be opened on the Variables view");
LuaInterface* lI = mpHost->getLuaInterface();
VarUnit* vu = lI->getVarUnit();
lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState();
QCOMPARE(luaL_dostring(L, "varsViewTable = {seedMember = 'seed member value'}"), 0);
vu->savedVars.insert(qsl("varsViewTable"));
vu->savedVars.insert(qsl("varsViewTable.seedMember"));
mpEditor->repopulateVars();
// a script running afterwards, with the view still up
QCOMPARE(luaL_dostring(L, "varsViewTable.lateMember = 'late member value'"), 0);
QCOMPARE(luaL_dostring(L, "varsViewTable.seedMember = nil"), 0);
const QString xml = exportProfileXml();
QVERIFY(!xml.isEmpty());
QVERIFY2(xml.contains(qsl("late member value")), "a member added while the Variables view was open must still be saved");
// secondary: writeVariable() re-reads values from Lua, so a stale tree
// writes this one out empty rather than with its old value
QVERIFY2(!xml.contains(qsl("seed member value")), "a member a script removed while the Variables view was open must not be saved back");
auto* pVariablesTree = mpEditor->findChild<QTreeWidget*>(qsl("treeWidget_variables"));
QVERIFY2(pVariablesTree, "the editor has no variables tree widget");
QTreeWidgetItem* pBaseItem = pVariablesTree->topLevelItem(0);
QVERIFY2(pBaseItem && pBaseItem->childCount() > 0, "the Variables view did not populate");
QVERIFY2(vu->getWVar(pBaseItem->child(0)), "a save taken with the Variables view on screen must leave its items resolving to their variables");
vu->savedVars.remove(qsl("varsViewTable"));
vu->savedVars.remove(qsl("varsViewTable.seedMember"));
QCOMPARE(luaL_dostring(L, "varsViewTable = nil"), 0);
}
// ... and the same for a whole variable rather than a table member.
void test_lateSavedVariableIsExportedWithVariablesViewOpen()
{
QVERIFY2(showEditorOnVariablesView(), "the script editor could not be opened on the Variables view");
LuaInterface* lI = mpHost->getLuaInterface();
VarUnit* vu = lI->getVarUnit();
mpEditor->repopulateVars();
lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState();
QCOMPARE(luaL_dostring(L, "varsViewLateVar = 'late variable value'"), 0);
vu->savedVars.insert(qsl("varsViewLateVar"));
const QString xml = exportProfileXml();
QVERIFY(!xml.isEmpty());
QVERIFY2(xml.contains(qsl("late variable value")), "a saved variable created while the Variables view was open must still be saved");
vu->savedVars.remove(qsl("varsViewLateVar"));
QCOMPARE(luaL_dostring(L, "varsViewLateVar = nil"), 0);
}
// The other side: a save must not pull the tree out from under the editor.
// Its tree widget and search results resolve items through VarUnit's
// item -> TVar map, which rebuilding the shared tree empties.
void test_variablesEditorItemMappingSurvivesExport()
{
QVERIFY2(showEditorOnVariablesView(), "the script editor could not be opened on the Variables view");
mpEditor->repopulateVars();
VarUnit* vu = mpHost->getLuaInterface()->getVarUnit();
auto* pVariablesTree = mpEditor->findChild<QTreeWidget*>(qsl("treeWidget_variables"));
QVERIFY2(pVariablesTree, "the editor has no variables tree widget");
QTreeWidgetItem* pBaseItem = pVariablesTree->topLevelItem(0);
QVERIFY2(pBaseItem && pBaseItem->childCount() > 0, "the Variables view did not populate");
QTreeWidgetItem* pVariableItem = pBaseItem->child(0);
TVar* pMappedBefore = vu->getWVar(pVariableItem);
QVERIFY2(pMappedBefore, "the Variables view's items should resolve to a variable");
// any save does it: the Save Profile button, the autosave, a package change
mpEditor->slot_showTriggers();
QVERIFY(!exportProfileXml().isEmpty());
QVERIFY2(vu->getWVar(pVariableItem) == pMappedBefore, "a profile save must leave the Variables editor's items resolving to their variables");
}
// What the user actually cares about: the data is there again next session.
// Declared last because importing puts the whole package back into the live
// profile, which the other tests would then be sharing.
void test_savedTableReachedByAnotherGlobalSurvivesAReload()
{
LuaInterface* lI = mpHost->getLuaInterface();
VarUnit* vu = lI->getVarUnit();
lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState();
// as many aliases as test_savedTableReachedByAnotherGlobalIsExported
// uses, and for the same reason
QCOMPARE(luaL_dostring(L,
"reloadSavedTable = {member = 'reload member value', nest = {deep = 'reload deep value'}} "
"aaaAliasOfReload = reloadSavedTable bAliasOfReload = reloadSavedTable m1AliasOfReload = reloadSavedTable "
"xyzzyAliasOfReload = reloadSavedTable alphaAliasOfReload = reloadSavedTable ref1AliasOfReload = reloadSavedTable "
"A_1AliasOfReload = reloadSavedTable"),
0);
for (const auto& name : {qsl("reloadSavedTable"), qsl("reloadSavedTable.member"), qsl("reloadSavedTable.nest"), qsl("reloadSavedTable.nest.deep")}) {
vu->savedVars.insert(name);
}
const QString xmlPath = mudlet::getMudletPath(enums::profileHomePath, mHostname) + qsl("/reload-test.xml");
auto writer = std::make_shared<XMLexport>(mpHost);
QVERIFY2(writer->exportPackage(xmlPath, true, false), "the profile could not be exported");
QCOMPARE(luaL_dostring(L, "reloadSavedTable, aaaAliasOfReload, bAliasOfReload, m1AliasOfReload, xyzzyAliasOfReload, alphaAliasOfReload, ref1AliasOfReload, A_1AliasOfReload = nil"), 0);
QCOMPARE(luaL_dostring(L, "assert(reloadSavedTable == nil)"), 0);
QFile file(xmlPath);
QVERIFY2(file.open(QFile::ReadOnly | QFile::Text), qPrintable(file.errorString()));
XMLimport importer(mpHost);
auto [imported, importError] = importer.importPackage(&file);
file.close();
QFile::remove(xmlPath);
QVERIFY2(imported, qPrintable(importError));
QVERIFY2(luaL_dostring(L, "assert(reloadSavedTable.member == 'reload member value')") == 0, "the saved table did not come back with its member after a save and reload");
QVERIFY2(luaL_dostring(L, "assert(reloadSavedTable.nest.deep == 'reload deep value')") == 0, "the saved table's nested member did not come back after a save and reload");
for (const auto& name : {qsl("reloadSavedTable"), qsl("reloadSavedTable.member"), qsl("reloadSavedTable.nest"), qsl("reloadSavedTable.nest.deep")}) {
vu->savedVars.remove(name);
}
QCOMPARE(luaL_dostring(L, "reloadSavedTable = nil"), 0);
}
private:
// Returns false rather than asserting: a QVERIFY here would only return from
// this helper, leaving the caller to dereference a null editor.
bool showEditorOnVariablesView()
{
if (!mpEditor) {
mudlet::self()->slot_showScriptDialog();
QTest::qWait(100);
mpEditor = mpHost->mpEditorDialog;
if (!mpEditor) {
return false;
}
}
mpEditor->slot_showVariables();
QTest::qWait(50);
return true;
}
QString exportProfileXml()
{
const QString xmlPath = mudlet::getMudletPath(enums::profileHomePath, mHostname) + qsl("/xmlexport-test.xml");
auto writer = std::make_shared<XMLexport>(mpHost);
if (!writer->exportPackage(xmlPath, true, false)) {
return {};
}
QFile file(xmlPath);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
return {};
}
const QString xml = QString::fromUtf8(file.readAll());
file.close();
QFile::remove(xmlPath);
return xml;
}
void startProfile(const QString& hostname, const QString& address, const QString& port)
{
QTimer::singleShot(0, qApp, [hostname, address, port]() {
mudlet::self()->startAutoLogin({});
QTest::qWait(100);
QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton);
QTest::qWait(100);
QTest::keyClicks(QApplication::focusWidget(), hostname);
QTest::qWait(100);
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab);
QTest::qWait(100);
QTest::keyClicks(QApplication::focusWidget(), address);
QTest::qWait(100);
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab);
QTest::qWait(100);
QTest::keyClicks(QApplication::focusWidget(), port);
QTest::qWait(100);
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Return);
});
QSignalSpy spy(mudlet::self(), &mudlet::signal_profileLoaded);
if (!spy.wait(2000)) {
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(1000)) {
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 initializeQRCResourcesForXMLexportVariablesTest()
{
#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 "XMLexportVariablesTest.moc"
QTEST_MAIN(XMLexportVariablesTest)