mirror of
https://github.com/Mudlet/Mudlet
synced 2026-08-13 18:26:27 -04:00
#### Brief overview of PR changes/additions - One budget of 100 covered every root trigger created while a line was processed, and tripping it deactivated all of them, so a script arming 101 unrelated triggers lost all 101. - Triggers created mid-line now carry the creation lineage they belong to and how many generations deep they sit in it. A batch is one generation however big it is; only a trigger that re-creates itself keeps adding generations, so that is the only shape the budget counts. The limit is 1000 generations, and only the runaway lineage is stopped and named. - Generations do not bound a lineage that widens as it deepens, so past 20000 creations on one line new triggers stop being offered that line. Nothing is disowned there - they are all still armed for the lines that follow. #### Motivation for adding to Mudlet #9697 fixed a real freeze, but its counter had no lineage, so it destroyed legitimate triggers along with the runaway. Any routine arming more than 100 triggers from a trigger loses them, permanent ones included, which is a regression against 4.22.0. #### Other info (issues closed, discussion etc) Fixes a regression introduced by merged #9697; release-blocking for 5.0. Test case: `lua fired=0; tempTrigger("GATE", function() for i=1,200 do tempTrigger("PAY", function() fired=fired+1 end) end end); feedTriggers("\nGATE\n"); feedTriggers("\nPAY\n"); print(fired)` - prints 0 before, 200 after. A/B against a shipped 4.22.0 binary (4.22.0 / 5.0 RC / this PR), counted on the line after the one that armed them: 1001 unrelated temp triggers 1001 / 0 / 1001; two scripts of 600 each 600+600 / 0+0 / 600+600; 1001 permanent triggers 1001 / 0 / 1001. #9697's freeze is still stopped and bounded (601 runaway lines in 8 s, RSS flat at ~820 MB) and its own tests still pass. Eleven new tests parameterise the creation count, and cover nested passes and folder/filter-chain children, which none of #9697's did - that is why this shipped. Assisted-by: Claude:claude-opus-5
778 lines
48 KiB
C++
778 lines
48 KiB
C++
/***************************************************************************
|
|
* Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org *
|
|
* *
|
|
* This program is free software; you can redistribute it and/or modify *
|
|
* it under the terms of the GNU General Public License as published by *
|
|
* the Free Software Foundation; either version 2 of the License, or *
|
|
* (at your option) any later version. *
|
|
* *
|
|
* This program is distributed in the hope that it will be useful, *
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
|
* GNU General Public License for more details. *
|
|
* *
|
|
* You should have received a copy of the GNU General Public License *
|
|
* along with this program; if not, write to the *
|
|
* Free Software Foundation, Inc., *
|
|
* 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. *
|
|
***************************************************************************/
|
|
|
|
#include <QtTest/QtTest>
|
|
|
|
#include "Host.h"
|
|
#include "MudletInstanceCoordinator.h"
|
|
#include "TLuaInterpreter.h"
|
|
#include "TMainConsole.h"
|
|
#include "TriggerUnit.h"
|
|
#include "TelnetServerStub.h"
|
|
#include "ctelnet.h"
|
|
#include "dlgConnectionProfiles.h"
|
|
#include "mudlet.h"
|
|
|
|
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 initializeQRCResources();
|
|
|
|
// A trigger created from another trigger's script (tempTrigger() & Co.) must
|
|
// still get to match the line being processed. That has been observable
|
|
// behaviour for as long as trigger processing iterated the live root-node
|
|
// std::list - a push_back lands in front of end(), so the new trigger was
|
|
// reached within the same pass - and room-capture scripts ("start capture on
|
|
// the room title line, grab it and the following lines") depend on it.
|
|
// Iterating a snapshot (introduced by #9267 to fix a use-after-free) silently
|
|
// deferred such triggers to the next line and broke those scripts.
|
|
class TriggerSameLineMatchTest : public QObject
|
|
{
|
|
Q_OBJECT
|
|
|
|
private:
|
|
TelnetServerStub* mpServer = nullptr;
|
|
const QString mpHostname = "Test-TriggerSameLineMatch";
|
|
const QString mpPort = "4000";
|
|
const QString mpLocalhost = "localhost";
|
|
|
|
private slots:
|
|
void initTestCase() { initializeQRCResources(); }
|
|
|
|
void init()
|
|
{
|
|
mpServer = new TelnetServerStub(qApp);
|
|
mpServer->start(mpLocalhost, mpPort.toUShort());
|
|
mudlet::start();
|
|
mudlet::self()->setupConfig();
|
|
mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator"));
|
|
mudlet::self()->init();
|
|
mudlet::self()->setStorePasswordsSecurely(false);
|
|
deleteProfileDirectory(mpHostname);
|
|
}
|
|
|
|
// The classic room-capture pattern: a trigger on the room title line
|
|
// creates a catch-all temp trigger, which must capture the title line
|
|
// itself, not start one line late.
|
|
void test_tempTriggerCreatedInTriggerMatchesCurrentLine()
|
|
{
|
|
startProfile(mpHostname, mpLocalhost, mpPort);
|
|
auto* host = mudlet::self()->getActiveHost();
|
|
QVERIFY(host);
|
|
host->mEchoLuaErrors = true;
|
|
|
|
host->getLuaInterpreter()->compileAndExecuteScript(qsl("captured = {}\n"
|
|
"tempRegexTrigger('^Room 74042', [=[\n"
|
|
" tempRegexTrigger('^(.*)$', [[table.insert(captured, matches[2])]], 200)\n"
|
|
"]=])\n"
|
|
"feedTriggers('Room 74042: The Bitter Almond Grove\\n')\n"
|
|
"feedTriggers('Exits: North South West\\n')\n"
|
|
"echo('CAPTURED=' .. table.concat(captured, '|') .. '#\\n')\n"));
|
|
|
|
QCOMPARE(host->getTriggerUnit()->processingDepth(), 0);
|
|
QVERIFY2(bufferContains(qsl("CAPTURED=Room 74042: The Bitter Almond Grove|Exits: North South West#")),
|
|
"Expected the temp trigger created on the room title line to capture that same line first, then the next line");
|
|
}
|
|
|
|
// The new trigger matches the current line after every pre-existing
|
|
// trigger, mirroring where the live-list iteration used to reach it (the
|
|
// end of the list), not right after its creator.
|
|
void test_newTriggerMatchesAfterExistingTriggers()
|
|
{
|
|
startProfile(mpHostname, mpLocalhost, mpPort);
|
|
auto* host = mudlet::self()->getActiveHost();
|
|
QVERIFY(host);
|
|
host->mEchoLuaErrors = true;
|
|
|
|
host->getLuaInterpreter()->compileAndExecuteScript(qsl("order = {}\n"
|
|
"tempRegexTrigger('^o$', [=[\n"
|
|
" table.insert(order, 'first')\n"
|
|
" tempRegexTrigger('^o$', [[table.insert(order, 'created')]])\n"
|
|
"]=])\n"
|
|
"tempRegexTrigger('^o$', [[table.insert(order, 'second')]])\n"
|
|
"feedTriggers('o\\n')\n"
|
|
"echo('ORDER=' .. table.concat(order, ',') .. '#\\n')\n"));
|
|
|
|
QCOMPARE(host->getTriggerUnit()->processingDepth(), 0);
|
|
QVERIFY2(bufferContains(qsl("ORDER=first,second,created#")), "Expected the mid-pass trigger to fire on the current line after all pre-existing triggers");
|
|
}
|
|
|
|
// A trigger created by a trigger that was itself created this pass must
|
|
// also match the current line - creation can chain within one line.
|
|
void test_chainedCreationAllMatchCurrentLine()
|
|
{
|
|
startProfile(mpHostname, mpLocalhost, mpPort);
|
|
auto* host = mudlet::self()->getActiveHost();
|
|
QVERIFY(host);
|
|
host->mEchoLuaErrors = true;
|
|
|
|
host->getLuaInterpreter()->compileAndExecuteScript(qsl("chain = {}\n"
|
|
"tempRegexTrigger('^c$', [==[\n"
|
|
" table.insert(chain, 'creator')\n"
|
|
" tempRegexTrigger('^c$', [=[\n"
|
|
" table.insert(chain, 'A')\n"
|
|
" tempRegexTrigger('^c$', [[table.insert(chain, 'B')]])\n"
|
|
" ]=])\n"
|
|
"]==])\n"
|
|
"feedTriggers('c\\n')\n"
|
|
"echo('CHAIN=' .. table.concat(chain, ',') .. '#\\n')\n"));
|
|
|
|
QCOMPARE(host->getTriggerUnit()->processingDepth(), 0);
|
|
QVERIFY2(bufferContains(qsl("CHAIN=creator,A,B#")), "Expected each generation of mid-pass triggers to still match the current line");
|
|
}
|
|
|
|
// A single-shot (expireAfter=1) temp trigger created mid-pass spends its
|
|
// one shot on the creating line and must not linger to the next one.
|
|
void test_singleShotFiresOnCreatingLine()
|
|
{
|
|
startProfile(mpHostname, mpLocalhost, mpPort);
|
|
auto* host = mudlet::self()->getActiveHost();
|
|
QVERIFY(host);
|
|
host->mEchoLuaErrors = true;
|
|
|
|
host->getLuaInterpreter()->compileAndExecuteScript(qsl("expiryLine = ''\n"
|
|
"tempRegexTrigger('^e', [=[\n"
|
|
" tempRegexTrigger('^(.*)$', [[expiryLine = expiryLine .. matches[2] .. ';']], 1)\n"
|
|
"]=], 1)\n"
|
|
"feedTriggers('e one\\n')\n"
|
|
"feedTriggers('e two\\n')\n"
|
|
"echo('EXPIRYLINE=' .. expiryLine .. '#\\n')\n"));
|
|
|
|
QCOMPARE(host->getTriggerUnit()->processingDepth(), 0);
|
|
QVERIFY2(bufferContains(qsl("EXPIRYLINE=e one;#")), "Expected the single-shot temp trigger to fire once, on the line that created it");
|
|
}
|
|
|
|
// tempLineTrigger(0, n, ...) created mid-pass starts counting from the
|
|
// current line, so its first capture is the creating line itself.
|
|
void test_lineTriggerStartsOnCurrentLine()
|
|
{
|
|
startProfile(mpHostname, mpLocalhost, mpPort);
|
|
auto* host = mudlet::self()->getActiveHost();
|
|
QVERIFY(host);
|
|
host->mEchoLuaErrors = true;
|
|
|
|
host->getLuaInterpreter()->compileAndExecuteScript(qsl("lineGrabs = {}\n"
|
|
"tempRegexTrigger('^lstart$', [=[\n"
|
|
" tempLineTrigger(0, 2, [[table.insert(lineGrabs, getCurrentLine())]])\n"
|
|
"]=], 1)\n"
|
|
"feedTriggers('lstart\\n')\n"
|
|
"feedTriggers('second\\n')\n"
|
|
"feedTriggers('third\\n')\n"
|
|
"echo('LINEGRABS=' .. table.concat(lineGrabs, ',') .. '#\\n')\n"));
|
|
|
|
QCOMPARE(host->getTriggerUnit()->processingDepth(), 0);
|
|
QVERIFY2(bufferContains(qsl("LINEGRABS=lstart,second#")), "Expected the mid-pass line trigger to grab the creating line and the one after it");
|
|
}
|
|
|
|
// With nested processing (the creator also calls feedTriggers()), the new
|
|
// trigger matches the nested line while it is being processed and still
|
|
// matches the outer line afterwards - in that order.
|
|
void test_nestedFeedTriggersMatchesBothLines()
|
|
{
|
|
startProfile(mpHostname, mpLocalhost, mpPort);
|
|
auto* host = mudlet::self()->getActiveHost();
|
|
QVERIFY(host);
|
|
host->mEchoLuaErrors = true;
|
|
|
|
host->getLuaInterpreter()->compileAndExecuteScript(qsl("nested = {}\n"
|
|
"tempRegexTrigger('^outer$', [=[\n"
|
|
" tempRegexTrigger('^(.*)$', [[table.insert(nested, matches[2])]], 10)\n"
|
|
" feedTriggers('inner\\n')\n"
|
|
"]=], 1)\n"
|
|
"feedTriggers('outer\\n')\n"
|
|
"echo('NESTED=' .. table.concat(nested, ',') .. '#\\n')\n"));
|
|
|
|
QCOMPARE(host->getTriggerUnit()->processingDepth(), 0);
|
|
QVERIFY2(bufferContains(qsl("NESTED=inner,outer#")), "Expected the mid-pass trigger to match the nested line first, then the outer line it was created on");
|
|
}
|
|
|
|
// The naive "one-shot that re-arms itself at the end of its own handler" is
|
|
// the shape users write. Without the budget this does not fail, it hangs.
|
|
void test_selfRecreatingTriggerIsStopped()
|
|
{
|
|
startProfile(mpHostname, mpLocalhost, mpPort);
|
|
auto* host = mudlet::self()->getActiveHost();
|
|
QVERIFY(host);
|
|
host->mEchoLuaErrors = true;
|
|
|
|
host->getLuaInterpreter()->compileAndExecuteScript(qsl("loopFires = 0\n"
|
|
"function arm()\n"
|
|
" tempRegexTrigger('^hploop$', [[loopFires = loopFires + 1; arm()]], 1)\n"
|
|
"end\n"
|
|
"arm()\n"
|
|
"feedTriggers('hploop\\n')\n"
|
|
"echo('LOOPFIRES=' .. loopFires .. '#\\n')\n"));
|
|
|
|
QCOMPARE(host->getTriggerUnit()->processingDepth(), 0);
|
|
QVERIFY2(bufferContains(qsl("Trigger processing stopped to prevent a freeze")), "Expected the same-line re-creation abort error in the console buffer");
|
|
// one fire from the trigger already there, then one per budgeted creation;
|
|
// the trailing # keeps the check from also passing on ten times the number
|
|
const int expectedFires = 1 + TriggerUnit::scmMaxSameLineGenerations;
|
|
QVERIFY2(bufferContains(qsl("LOOPFIRES=%1#").arg(expectedFires)), qPrintable(qsl("Expected the re-arming trigger to fire exactly %1 times").arg(expectedFires)));
|
|
}
|
|
|
|
void test_selfRecreatingTriggerAbortNamesTheTrigger()
|
|
{
|
|
startProfile(mpHostname, mpLocalhost, mpPort);
|
|
auto* host = mudlet::self()->getActiveHost();
|
|
QVERIFY(host);
|
|
host->mEchoLuaErrors = true;
|
|
|
|
host->getLuaInterpreter()->compileAndExecuteScript(qsl("function armNamed()\n"
|
|
" tempComplexRegexTrigger('hpWatcher', '^hpnamed$', [[armNamed()]], 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1)\n"
|
|
"end\n"
|
|
"armNamed()\n"
|
|
"feedTriggers('hpnamed\\n')\n"));
|
|
|
|
QCOMPARE(host->getTriggerUnit()->processingDepth(), 0);
|
|
QVERIFY2(bufferContains(qsl("trigger 'hpWatcher'")), "Expected the abort message to name the trigger that keeps re-creating itself");
|
|
}
|
|
|
|
void test_finiteCreationChainIsUnaffected()
|
|
{
|
|
startProfile(mpHostname, mpLocalhost, mpPort);
|
|
auto* host = mudlet::self()->getActiveHost();
|
|
QVERIFY(host);
|
|
host->mEchoLuaErrors = true;
|
|
|
|
host->getLuaInterpreter()->compileAndExecuteScript(qsl("chainFires = 0\n"
|
|
"function chainStep()\n"
|
|
" chainFires = chainFires + 1\n"
|
|
" if chainFires < 10 then\n"
|
|
" tempRegexTrigger('^chain$', [[chainStep()]], 1)\n"
|
|
" end\n"
|
|
"end\n"
|
|
"tempRegexTrigger('^chain$', [[chainStep()]], 1)\n"
|
|
"feedTriggers('chain\\n')\n"
|
|
"echo('CHAINFIRES=' .. chainFires .. '#\\n')\n"));
|
|
|
|
QCOMPARE(host->getTriggerUnit()->processingDepth(), 0);
|
|
QVERIFY2(bufferContains(qsl("CHAINFIRES=10#")), "Expected all ten generations of the finite chain to match the current line");
|
|
QVERIFY2(!bufferContains(qsl("Trigger processing stopped to prevent a freeze")), "A chain that ends on its own must not trip the same-line generation budget");
|
|
}
|
|
|
|
// Without disowning what the loop created, each line costs a multiple of the
|
|
// one before it, so the freeze is postponed rather than prevented.
|
|
void test_selfRecreatingTriggerDoesNotAccumulateAcrossLines()
|
|
{
|
|
startProfile(mpHostname, mpLocalhost, mpPort);
|
|
auto* host = mudlet::self()->getActiveHost();
|
|
QVERIFY(host);
|
|
host->mEchoLuaErrors = true;
|
|
|
|
host->getLuaInterpreter()->compileAndExecuteScript(qsl("keptFires = 0\n"
|
|
"function armKept()\n"
|
|
" tempRegexTrigger('^kept$', [[keptFires = keptFires + 1; armKept()]])\n"
|
|
"end\n"
|
|
"armKept()\n"
|
|
"feedTriggers('kept\\n')\n"
|
|
"feedTriggers('kept\\n')\n"
|
|
"echo('KEPTFIRES=' .. keptFires .. '#\\n')\n"));
|
|
|
|
QCOMPARE(host->getTriggerUnit()->processingDepth(), 0);
|
|
const int firesPerLine = 1 + TriggerUnit::scmMaxSameLineGenerations;
|
|
QVERIFY2(bufferContains(qsl("KEPTFIRES=%1#").arg(2 * firesPerLine)),
|
|
qPrintable(qsl("Expected the second line to cost the same %1 fires as the first, not a multiple of them").arg(firesPerLine)));
|
|
}
|
|
|
|
// Permanent triggers are saved with the profile, so they are stopped without
|
|
// being deleted and with deactivate(), which leaves the user-active state
|
|
// XMLexport writes alone.
|
|
void test_selfRecreatingPermanentTriggerIsStoppedButNotDeleted()
|
|
{
|
|
startProfile(mpHostname, mpLocalhost, mpPort);
|
|
auto* host = mudlet::self()->getActiveHost();
|
|
QVERIFY(host);
|
|
host->mEchoLuaErrors = true;
|
|
|
|
host->getLuaInterpreter()->compileAndExecuteScript(qsl("permFires = 0\n"
|
|
"function armPerm()\n"
|
|
" permRegexTrigger('Perm Loop', '', {'^permloop$'}, [[permFires = permFires + 1; armPerm()]])\n"
|
|
"end\n"
|
|
"armPerm()\n"
|
|
"feedTriggers('permloop\\n')\n"
|
|
"echo('PERMFIRES=' .. permFires .. '#\\n')\n"
|
|
"echo('PERMACTIVE=' .. isActive('Perm Loop', 'trigger') .. '#\\n')\n"
|
|
"echo('PERMEXISTS=' .. exists('Perm Loop', 'trigger') .. '#\\n')\n"));
|
|
|
|
const int expectedFires = 1 + TriggerUnit::scmMaxSameLineGenerations;
|
|
QCOMPARE(host->getTriggerUnit()->processingDepth(), 0);
|
|
QVERIFY2(bufferContains(qsl("Trigger processing stopped to prevent a freeze")), "Expected a permanent trigger re-creating itself to be stopped too");
|
|
QVERIFY2(bufferContains(qsl("PERMFIRES=%1#").arg(expectedFires)), qPrintable(qsl("Expected the re-arming permanent trigger to fire exactly %1 times").arg(expectedFires)));
|
|
QVERIFY2(bufferContains(qsl("PERMACTIVE=1#")), "Expected only the trigger that predates the line to still be active");
|
|
QVERIFY2(bufferContains(qsl("PERMEXISTS=%1#").arg(expectedFires + 1)), "Expected the stopped permanent triggers to still exist - stopping them is not deleting them");
|
|
}
|
|
|
|
// A script arming a batch of unrelated triggers is not a runaway, however
|
|
// big the batch: each of them starts a creation chain of its own, and none
|
|
// of those chains ever gets a second link.
|
|
void test_bulkUnrelatedCreationsAreNotStopped()
|
|
{
|
|
startProfile(mpHostname, mpLocalhost, mpPort);
|
|
auto* host = mudlet::self()->getActiveHost();
|
|
QVERIFY(host);
|
|
host->mEchoLuaErrors = true;
|
|
|
|
const int bulkCount = TriggerUnit::scmMaxSameLineGenerations + 1;
|
|
host->getLuaInterpreter()->compileAndExecuteScript(qsl("bulkFires = 0\n"
|
|
"tempRegexTrigger('^bulkgate$', [=[\n"
|
|
" for i = 1, %1 do\n"
|
|
" tempRegexTrigger('^bulkpay$', [[bulkFires = bulkFires + 1]])\n"
|
|
" end\n"
|
|
"]=], 1)\n"
|
|
"feedTriggers('bulkgate\\n')\n"
|
|
"feedTriggers('bulkpay\\n')\n"
|
|
"echo('BULKFIRES=' .. bulkFires .. '#\\n')\n")
|
|
.arg(bulkCount));
|
|
|
|
QCOMPARE(host->getTriggerUnit()->processingDepth(), 0);
|
|
QVERIFY2(!bufferContains(qsl("Trigger processing stopped to prevent a freeze")), "A batch of unrelated triggers must not be mistaken for a trigger re-creating itself");
|
|
QVERIFY2(bufferContains(qsl("BULKFIRES=%1#").arg(bulkCount)), qPrintable(qsl("Expected all %1 triggers armed on the previous line to survive and fire").arg(bulkCount)));
|
|
}
|
|
|
|
// Two scripts arming triggers on one line get a budget each, so neither can
|
|
// exhaust the other's - together they come to more than one budget's worth.
|
|
void test_twoScriptsArmingOnOneLineKeepBothSetsOfTriggers()
|
|
{
|
|
startProfile(mpHostname, mpLocalhost, mpPort);
|
|
auto* host = mudlet::self()->getActiveHost();
|
|
QVERIFY(host);
|
|
host->mEchoLuaErrors = true;
|
|
|
|
const int eachCount = (TriggerUnit::scmMaxSameLineGenerations / 2) + 1;
|
|
host->getLuaInterpreter()->compileAndExecuteScript(qsl("firesA, firesB = 0, 0\n"
|
|
"tempRegexTrigger('^sharedgate$', [=[\n"
|
|
" for i = 1, %1 do\n"
|
|
" tempRegexTrigger('^payA$', [[firesA = firesA + 1]])\n"
|
|
" end\n"
|
|
"]=], 1)\n"
|
|
"tempRegexTrigger('^sharedgate$', [=[\n"
|
|
" for i = 1, %1 do\n"
|
|
" tempRegexTrigger('^payB$', [[firesB = firesB + 1]])\n"
|
|
" end\n"
|
|
"]=], 1)\n"
|
|
"feedTriggers('sharedgate\\n')\n"
|
|
"feedTriggers('payA\\n')\n"
|
|
"feedTriggers('payB\\n')\n"
|
|
"echo('SHARED=' .. firesA .. ',' .. firesB .. '#\\n')\n")
|
|
.arg(eachCount));
|
|
|
|
QCOMPARE(host->getTriggerUnit()->processingDepth(), 0);
|
|
QVERIFY2(bufferContains(qsl("SHARED=%1,%1#").arg(eachCount)), qPrintable(qsl("Expected both scripts to keep all %1 of the triggers they armed").arg(eachCount)));
|
|
}
|
|
|
|
// The batch is armed by a trigger that was itself created on this line, so
|
|
// creator and batch share a lineage. Counting a lineage's members rather than
|
|
// its generations condemns the whole batch here, which is the room-capture
|
|
// shape: the room-title trigger creates the capture trigger, and the capture
|
|
// trigger is what arms the batch.
|
|
void test_bulkCreationsFromAMidLineTriggerAreNotStopped()
|
|
{
|
|
startProfile(mpHostname, mpLocalhost, mpPort);
|
|
auto* host = mudlet::self()->getActiveHost();
|
|
QVERIFY(host);
|
|
host->mEchoLuaErrors = true;
|
|
|
|
const int bulkCount = TriggerUnit::scmMaxSameLineGenerations + 1;
|
|
host->getLuaInterpreter()->compileAndExecuteScript(qsl("deepFires = 0\n"
|
|
"tempRegexTrigger('^deepgate$', [===[\n"
|
|
" tempRegexTrigger('^deepgate$', [==[\n"
|
|
" for i = 1, %1 do\n"
|
|
" tempRegexTrigger('^deeppay$', [[deepFires = deepFires + 1]])\n"
|
|
" end\n"
|
|
" ]==], 1)\n"
|
|
"]===], 1)\n"
|
|
"feedTriggers('deepgate\\n')\n"
|
|
"feedTriggers('deeppay\\n')\n"
|
|
"echo('DEEPFIRES=' .. deepFires .. '#\\n')\n")
|
|
.arg(bulkCount));
|
|
|
|
QCOMPARE(host->getTriggerUnit()->processingDepth(), 0);
|
|
QVERIFY2(!bufferContains(qsl("Trigger processing stopped to prevent a freeze")), "A batch is one generation wherever it is armed from, and must not be mistaken for a runaway");
|
|
QVERIFY2(bufferContains(qsl("DEEPFIRES=%1#").arg(bulkCount)), qPrintable(qsl("Expected all %1 triggers armed by a trigger created on the same line to survive and fire").arg(bulkCount)));
|
|
}
|
|
|
|
// Permanent triggers take the same path, and are the more painful loss - a
|
|
// "rebuild my triggers when the game says X" routine arms them in bulk.
|
|
void test_bulkPermanentCreationsAreNotStopped()
|
|
{
|
|
startProfile(mpHostname, mpLocalhost, mpPort);
|
|
auto* host = mudlet::self()->getActiveHost();
|
|
QVERIFY(host);
|
|
host->mEchoLuaErrors = true;
|
|
|
|
const int bulkCount = TriggerUnit::scmMaxSameLineGenerations + 1;
|
|
host->getLuaInterpreter()->compileAndExecuteScript(qsl("permBulkFires = 0\n"
|
|
"function permBulkStep() permBulkFires = permBulkFires + 1 end\n"
|
|
"tempRegexTrigger('^permgate$', [=[\n"
|
|
" for i = 1, %1 do\n"
|
|
" permRegexTrigger('PermBulk' .. i, '', {'^permpay$'}, [[permBulkStep()]])\n"
|
|
" end\n"
|
|
"]=], 1)\n"
|
|
"feedTriggers('permgate\\n')\n"
|
|
"feedTriggers('permpay\\n')\n"
|
|
"echo('PERMBULK=' .. permBulkFires .. '#\\n')\n"
|
|
"echo('PERMBULKACTIVE=' .. isActive('PermBulk%1', 'trigger') .. '#\\n')\n")
|
|
.arg(bulkCount));
|
|
|
|
QCOMPARE(host->getTriggerUnit()->processingDepth(), 0);
|
|
QVERIFY2(bufferContains(qsl("PERMBULK=%1#").arg(bulkCount)), qPrintable(qsl("Expected all %1 permanent triggers armed on the previous line to survive and fire").arg(bulkCount)));
|
|
QVERIFY2(bufferContains(qsl("PERMBULKACTIVE=1#")), "Expected the permanent triggers to be left switched on");
|
|
}
|
|
|
|
// The whole point of the budget being per chain: the runaway loses its
|
|
// triggers, the script that happened to arm a trigger on the same line does not.
|
|
void test_runawayChainSparesTriggersFromOtherScripts()
|
|
{
|
|
startProfile(mpHostname, mpLocalhost, mpPort);
|
|
auto* host = mudlet::self()->getActiveHost();
|
|
QVERIFY(host);
|
|
host->mEchoLuaErrors = true;
|
|
|
|
host->getLuaInterpreter()->compileAndExecuteScript(qsl("innocentFires = 0\n"
|
|
"function armRunaway()\n"
|
|
" tempRegexTrigger('^runline$', [[armRunaway()]], 1)\n"
|
|
"end\n"
|
|
"armRunaway()\n"
|
|
"tempRegexTrigger('^runline$', [=[\n"
|
|
" tempRegexTrigger('^innocent$', [[innocentFires = innocentFires + 1]])\n"
|
|
"]=], 1)\n"
|
|
"feedTriggers('runline\\n')\n"
|
|
"feedTriggers('innocent\\n')\n"
|
|
"echo('INNOCENT=' .. innocentFires .. '#\\n')\n"));
|
|
|
|
QCOMPARE(host->getTriggerUnit()->processingDepth(), 0);
|
|
QVERIFY2(bufferContains(qsl("Trigger processing stopped to prevent a freeze")), "Expected the self-recreating chain to still be stopped");
|
|
QVERIFY2(bufferContains(qsl("INNOCENT=1#")), "Expected the trigger armed by an unrelated script on the same line to survive the runaway's abort and fire");
|
|
}
|
|
|
|
// A lineage of exactly the budget's depth ends on its own; the trip is on the
|
|
// generation after it, which test_selfRecreatingTriggerIsStopped() pins from
|
|
// the other side. Both land on the same fire count, so the presence or
|
|
// absence of the abort message is what tells the two apart.
|
|
void test_chainExactlyAtTheLimitIsNotStopped()
|
|
{
|
|
startProfile(mpHostname, mpLocalhost, mpPort);
|
|
auto* host = mudlet::self()->getActiveHost();
|
|
QVERIFY(host);
|
|
host->mEchoLuaErrors = true;
|
|
|
|
const int limit = TriggerUnit::scmMaxSameLineGenerations;
|
|
host->getLuaInterpreter()->compileAndExecuteScript(qsl("boundFires = 0\n"
|
|
"function boundStep()\n"
|
|
" boundFires = boundFires + 1\n"
|
|
" if boundFires <= %1 then\n"
|
|
" tempRegexTrigger('^boundline$', [[boundStep()]], 1)\n"
|
|
" end\n"
|
|
"end\n"
|
|
"tempRegexTrigger('^boundline$', [[boundStep()]], 1)\n"
|
|
"feedTriggers('boundline\\n')\n"
|
|
"echo('BOUNDFIRES=' .. boundFires .. '#\\n')\n")
|
|
.arg(limit));
|
|
|
|
QCOMPARE(host->getTriggerUnit()->processingDepth(), 0);
|
|
QVERIFY2(!bufferContains(qsl("Trigger processing stopped to prevent a freeze")), "A chain of exactly the budget's length ends on its own and must not be stopped");
|
|
QVERIFY2(bufferContains(qsl("BOUNDFIRES=%1#").arg(limit + 1)), qPrintable(qsl("Expected the chain to run to its own end, %1 fires").arg(limit + 1)));
|
|
}
|
|
|
|
// Once the line that created a trigger is done with, that trigger is as
|
|
// ordinary as any other and what it creates starts fresh chains - otherwise
|
|
// it would carry its creator's chain around for the rest of the session.
|
|
void test_aTriggerOutlivingItsLineStartsFreshChains()
|
|
{
|
|
startProfile(mpHostname, mpLocalhost, mpPort);
|
|
auto* host = mudlet::self()->getActiveHost();
|
|
QVERIFY(host);
|
|
host->mEchoLuaErrors = true;
|
|
|
|
const int bulkCount = TriggerUnit::scmMaxSameLineGenerations + 1;
|
|
host->getLuaInterpreter()->compileAndExecuteScript(qsl("laterFires = 0\n"
|
|
"tempRegexTrigger('^egate$', [==[\n"
|
|
" tempRegexTrigger('^esecond$', [=[\n"
|
|
" for i = 1, %1 do\n"
|
|
" tempRegexTrigger('^epay$', [[laterFires = laterFires + 1]])\n"
|
|
" end\n"
|
|
" ]=], 1)\n"
|
|
"]==], 1)\n"
|
|
"feedTriggers('egate\\n')\n"
|
|
"feedTriggers('esecond\\n')\n"
|
|
"feedTriggers('epay\\n')\n"
|
|
"echo('LATERFIRES=' .. laterFires .. '#\\n')\n")
|
|
.arg(bulkCount));
|
|
|
|
QCOMPARE(host->getTriggerUnit()->processingDepth(), 0);
|
|
QVERIFY2(!bufferContains(qsl("Trigger processing stopped to prevent a freeze")), "A trigger created on an earlier line is not part of a chain any more and must arm freely");
|
|
QVERIFY2(bufferContains(qsl("LATERFIRES=%1#").arg(bulkCount)), qPrintable(qsl("Expected all %1 triggers armed on the later line to survive and fire").arg(bulkCount)));
|
|
}
|
|
|
|
// A lineage that starts in the outer pass and runs away inside a nested
|
|
// feedTriggers() has members either side of the nested pass's first-node
|
|
// index, which is why stopping one scans the whole list rather than the
|
|
// tail of the tripping pass. Scanning only the tail leaves the first link
|
|
// alive, and the outer pass then has to trip on the same lineage all over
|
|
// again - the fire count is what shows that, at twice this number.
|
|
void test_runawayCrossingIntoANestedPassIsStoppedWhole()
|
|
{
|
|
startProfile(mpHostname, mpLocalhost, mpPort);
|
|
auto* host = mudlet::self()->getActiveHost();
|
|
QVERIFY(host);
|
|
host->mEchoLuaErrors = true;
|
|
|
|
host->getLuaInterpreter()->compileAndExecuteScript(qsl("nestFires, nestSafeFires = 0, 0\n"
|
|
"function armNested()\n"
|
|
" tempRegexTrigger('^nestin$', [[nestFires = nestFires + 1; armNested()]])\n"
|
|
"end\n"
|
|
"tempRegexTrigger('^nestout$', [=[\n"
|
|
" armNested()\n"
|
|
" tempRegexTrigger('^nestsafe$', [[nestSafeFires = nestSafeFires + 1]])\n"
|
|
" feedTriggers('nestin\\n')\n"
|
|
"]=], 1)\n"
|
|
"feedTriggers('nestout\\n')\n"
|
|
"echo('NESTFIRES=' .. nestFires .. '#\\n')\n"
|
|
"nestFires = 0\n"
|
|
"feedTriggers('nestin\\n')\n"
|
|
"feedTriggers('nestsafe\\n')\n"
|
|
"echo('NESTAFTER=' .. nestFires .. '#\\n')\n"
|
|
"echo('NESTSAFE=' .. nestSafeFires .. '#\\n')\n"));
|
|
|
|
QCOMPARE(host->getTriggerUnit()->processingDepth(), 0);
|
|
QVERIFY2(bufferContains(qsl("Trigger processing stopped to prevent a freeze")), "Expected a runaway that crosses into a nested pass to be stopped");
|
|
QVERIFY2(bufferContains(qsl("NESTFIRES=%1#").arg(TriggerUnit::scmMaxSameLineGenerations)), "Expected the runaway to cost one budget, not one per pass the lineage is spread across");
|
|
QVERIFY2(bufferContains(qsl("NESTAFTER=0#")), "Expected no member of the stopped lineage to be left armed, wherever in the list it sat");
|
|
QVERIFY2(bufferContains(qsl("NESTSAFE=1#")), "Expected a trigger armed by an unrelated script on the outer line to survive the nested pass's abort");
|
|
}
|
|
|
|
// Creations made inside a nested pass are appended to the same list the outer
|
|
// pass is walking, so a batch armed there has to be read as one generation
|
|
// just the same.
|
|
void test_bulkCreationsInsideANestedPassAreNotStopped()
|
|
{
|
|
startProfile(mpHostname, mpLocalhost, mpPort);
|
|
auto* host = mudlet::self()->getActiveHost();
|
|
QVERIFY(host);
|
|
host->mEchoLuaErrors = true;
|
|
|
|
const int bulkCount = TriggerUnit::scmMaxSameLineGenerations + 1;
|
|
host->getLuaInterpreter()->compileAndExecuteScript(qsl("crossFires = 0\n"
|
|
"tempRegexTrigger('^crossout$', [==[\n"
|
|
" tempRegexTrigger('^crossin$', [=[\n"
|
|
" for i = 1, %1 do\n"
|
|
" tempRegexTrigger('^crosspay$', [[crossFires = crossFires + 1]])\n"
|
|
" end\n"
|
|
" ]=], 1)\n"
|
|
" feedTriggers('crossin\\n')\n"
|
|
"]==], 1)\n"
|
|
"feedTriggers('crossout\\n')\n"
|
|
"feedTriggers('crosspay\\n')\n"
|
|
"echo('CROSSFIRES=' .. crossFires .. '#\\n')\n")
|
|
.arg(bulkCount));
|
|
|
|
QCOMPARE(host->getTriggerUnit()->processingDepth(), 0);
|
|
QVERIFY2(!bufferContains(qsl("Trigger processing stopped to prevent a freeze")), "A batch armed inside a nested pass is still one generation and must not be stopped");
|
|
QVERIFY2(bufferContains(qsl("CROSSFIRES=%1#").arg(bulkCount)), qPrintable(qsl("Expected all %1 triggers armed inside the nested pass to survive and fire").arg(bulkCount)));
|
|
}
|
|
|
|
// Only root triggers carry a lineage, so a trigger sitting in a folder creates
|
|
// on the folder's behalf. Read the child's own (always empty) lineage instead
|
|
// and every round would start a fresh one, which never deepens and so never
|
|
// trips - the run would only end at the per-line creation ceiling.
|
|
void test_folderChildCreatesOnItsRootsBehalf()
|
|
{
|
|
startProfile(mpHostname, mpLocalhost, mpPort);
|
|
auto* host = mudlet::self()->getActiveHost();
|
|
QVERIFY(host);
|
|
host->mEchoLuaErrors = true;
|
|
|
|
host->getLuaInterpreter()->compileAndExecuteScript(qsl("folderFires, folderCount = 0, 0\n"
|
|
"function makeFolderGen()\n"
|
|
" folderCount = folderCount + 1\n"
|
|
" local name = 'FGen' .. folderCount\n"
|
|
" permGroup(name, 'trigger')\n"
|
|
" permRegexTrigger('FChild' .. folderCount, name, {'^folderloop$'}, [[folderFires = folderFires + 1; makeFolderGen()]])\n"
|
|
"end\n"
|
|
"makeFolderGen()\n"
|
|
"feedTriggers('folderloop\\n')\n"
|
|
"echo('FOLDERFIRES=' .. folderFires .. '#\\n')\n"));
|
|
|
|
QCOMPARE(host->getTriggerUnit()->processingDepth(), 0);
|
|
QVERIFY2(bufferContains(qsl("Trigger processing stopped to prevent a freeze")), "Expected a runaway driven from inside a folder to be stopped");
|
|
QVERIFY2(bufferContains(qsl("FOLDERFIRES=%1#").arg(1 + TriggerUnit::scmMaxSameLineGenerations)),
|
|
"Expected the folder's lineage to deepen by one per round, so the generation budget is what ends it");
|
|
}
|
|
|
|
// The same for a filter chain, where the child is reached through the parent's
|
|
// capture rather than by the root list passing data down.
|
|
void test_filterChainChildCreatesOnItsRootsBehalf()
|
|
{
|
|
startProfile(mpHostname, mpLocalhost, mpPort);
|
|
auto* host = mudlet::self()->getActiveHost();
|
|
QVERIFY(host);
|
|
host->mEchoLuaErrors = true;
|
|
|
|
host->getLuaInterpreter()->compileAndExecuteScript(qsl("filterFires, filterCount = 0, 0\n"
|
|
"function makeFilterGen()\n"
|
|
" filterCount = filterCount + 1\n"
|
|
" local name = 'FiltP' .. filterCount\n"
|
|
" tempComplexRegexTrigger(name, '^(filterloop)$', '', 0, 0, 0, 1, 0, 0, 0, 0, 0, 0)\n"
|
|
" permRegexTrigger('FiltC' .. filterCount, name, {'filterloop'}, [[filterFires = filterFires + 1; makeFilterGen()]])\n"
|
|
"end\n"
|
|
"makeFilterGen()\n"
|
|
"feedTriggers('filterloop\\n')\n"
|
|
"echo('FILTERFIRES=' .. filterFires .. '#\\n')\n"));
|
|
|
|
QCOMPARE(host->getTriggerUnit()->processingDepth(), 0);
|
|
QVERIFY2(bufferContains(qsl("Trigger processing stopped to prevent a freeze")), "Expected a runaway driven from inside a filter chain to be stopped");
|
|
QVERIFY2(bufferContains(qsl("FILTERFIRES=%1#").arg(1 + TriggerUnit::scmMaxSameLineGenerations)),
|
|
"Expected the filter parent's lineage to deepen by one per round, so the generation budget is what ends it");
|
|
}
|
|
|
|
// The outer line's own mid-pass triggers were registered before the nested
|
|
// pass began, so its abort must not take them.
|
|
void test_nestedPassAbortLeavesTheOuterLineAlone()
|
|
{
|
|
startProfile(mpHostname, mpLocalhost, mpPort);
|
|
auto* host = mudlet::self()->getActiveHost();
|
|
QVERIFY(host);
|
|
host->mEchoLuaErrors = true;
|
|
|
|
host->getLuaInterpreter()->compileAndExecuteScript(qsl("seen = {}\n"
|
|
"function armInner()\n"
|
|
" tempRegexTrigger('^inner$', [[armInner()]], 1)\n"
|
|
"end\n"
|
|
"armInner()\n"
|
|
"tempRegexTrigger('^outer$', [=[\n"
|
|
" tempRegexTrigger('^(.*)$', [[table.insert(seen, matches[2])]], 10)\n"
|
|
" feedTriggers('inner\\n')\n"
|
|
"]=], 1)\n"
|
|
"feedTriggers('outer\\n')\n"
|
|
"echo('SEEN=' .. table.concat(seen, ',') .. '#\\n')\n"));
|
|
|
|
QCOMPARE(host->getTriggerUnit()->processingDepth(), 0);
|
|
QVERIFY2(bufferContains(qsl("Trigger processing stopped to prevent a freeze")), "Expected the runaway in the nested pass to be stopped");
|
|
QVERIFY2(bufferContains(qsl("SEEN=inner,outer#")), "Expected the capture trigger created by the outer line to survive the nested pass's abort and still match the outer line");
|
|
}
|
|
|
|
// Not a feedTriggers() curiosity - real socket text takes the same path - and
|
|
// driving it from the socket also proves the abort leaves the event loop running.
|
|
void test_selfRecreatingTriggerFromServerTextIsStopped()
|
|
{
|
|
startProfile(mpHostname, mpLocalhost, mpPort);
|
|
auto* host = mudlet::self()->getActiveHost();
|
|
QVERIFY(host);
|
|
host->mEchoLuaErrors = true;
|
|
|
|
host->getLuaInterpreter()->compileAndExecuteScript(qsl("function armFromServer()\n"
|
|
" tempRegexTrigger('^HP: 100/100$', [[armFromServer()]], 1)\n"
|
|
"end\n"
|
|
"armFromServer()\n"));
|
|
|
|
mpServer->sendRaw(QByteArray("HP: 100/100\r\n"));
|
|
QTRY_VERIFY2_WITH_TIMEOUT(bufferContains(qsl("Trigger processing stopped to prevent a freeze")), "Expected server text to reach the same-line generation budget and be stopped", 10000);
|
|
QCOMPARE(host->getTriggerUnit()->processingDepth(), 0);
|
|
}
|
|
|
|
void cleanup()
|
|
{
|
|
delete mpServer;
|
|
mpServer = nullptr;
|
|
deleteProfileDirectory(mpHostname);
|
|
delete mudlet::self();
|
|
}
|
|
|
|
// Starts a profile the way a user would via the GUI (mirrors the helper in
|
|
// TelnetTextDisplayedTest).
|
|
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(5000)) {
|
|
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(2000)) {
|
|
QFAIL("Could not connect with the host.");
|
|
}
|
|
}
|
|
|
|
// Joins every physical buffer line and normalises whitespace before
|
|
// matching, so a needle the console word-wraps across lines is still found.
|
|
QString joinedBuffer()
|
|
{
|
|
auto console = mudlet::self()->getActiveHost()->mpConsole;
|
|
QString allText;
|
|
for (int i = 0; i <= console->buffer.getLastLineNumber(); ++i) {
|
|
allText.append(console->buffer.line(i)).append(QChar::Space);
|
|
}
|
|
return allText.simplified();
|
|
}
|
|
|
|
bool bufferContains(const QString& needle) { return joinedBuffer().contains(needle); }
|
|
|
|
void deleteProfileDirectory(const QString& profileName)
|
|
{
|
|
const QString path = mudlet::getMudletPath(enums::profileHomePath, profileName);
|
|
QDir dir(path);
|
|
if (!dir.exists()) {
|
|
return;
|
|
}
|
|
dir.removeRecursively();
|
|
}
|
|
};
|
|
|
|
void initializeQRCResources()
|
|
{
|
|
#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 "TriggerSameLineMatchTest.moc"
|
|
QTEST_MAIN(TriggerSameLineMatchTest)
|