mudlet/test/functional_tests/XMLexportVariablesTest.cpp
Vadim Peretokin 6c2d2444b2
fix: profile saves silently dropping saved variables that a second global references (#9762)
#### Brief overview of PR changes/additions
- A profile save read a fresh variable tree by walking all of `_G`
first-seen-wins, so a saved table another global name reached first was
filed under that name and written nowhere. Saves now read only the
globals the profile saves, each in its own dedup scope, and a name the
user saves is never deduped away.
- Same change takes the save off the size of `_G`: 0.269 s -> 0.007 s
per save at 20,000 globals, 0.013 s -> 0.001 s on a default profile
(4.22.0 is 0.002 s).
- `iterateTable()` names the table when it stops at 99 levels of nesting
instead of handing back an empty one, and the save tells the user which
saved variables that leaves empty.

#### Motivation for adding to Mudlet
Silent, permanent data loss on every save with no user action: a stock
4.x profile with EMCO/AdjustableContainer packages lost 1416 of its 1444
saved variable entries on the first 5.0 session.

#### Other info (issues closed, discussion etc)
Fixes #9755. Keeps #9704's fix (quitting with the editor on the
Variables tab) intact - the export still builds a throwaway tree, so the
Variables editor's tree items are never stranded.

Measured on a real profile (`Pox`, fresh isolated HOME): 25 variables /
4 groups / 4.3 KB before, 2267 / 535 / 497 KB after, identical on a
second session. 4.22.0 wrote 1163 / 281 / 258 KB. The difference above
4.22.0 is the live EMCO and AdjustableContainer objects the profile
keeps inside its saved `demonnic` table, which the ride-along rule from
#9517 says to save.

**Test case:** `lua qaShared = {a = "alpha"}`, tick `qaShared` in the
editor's Variables view, then `lua aaaAliasOfShared = qaShared`, quit
and reopen - `qaShared.a` is still there. `ctest -R
XMLexportVariablesTest` covers it; 5 of the 8 new cases were verified to
fail against the unfixed source.
2026-08-12 21:27:46 +02:00

941 lines
46 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).
*
* 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, so they must not ride along either.
void test_functionMemberOfSavedTableIsNotExported()
{
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("data member value")), "a plain member of a saved table must be exported");
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)