/*************************************************************************** * 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 #include #include "EditorUndoStack.h" #include "Host.h" #include "MudletInstanceCoordinator.h" #include "ScriptUnit.h" #include "TScript.h" #include "TTreeWidget.h" #include "TelnetServerStub.h" #include "ctelnet.h" #include "dlgConnectionProfiles.h" #include "dlgScriptsMainArea.h" #include "dlgTriggerEditor.h" #include "mudlet.h" using namespace std::chrono_literals; extern void qInitResources_mudlet(); extern void qInitResources_qm(); extern void qInitResources_additional_splash_screens(); extern void qInitResources_mudlet_fonts_common(); extern void qInitResources_mudlet_fonts_posix(); static 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(); } // Run with: ctest -R ScriptEventHandlerLifetimeTest -V // // These pin down the noted "Add User Event" item being dropped whenever the "Registered // Events" list is torn down, so "+" cannot reach a freed item (#9835). For why the note // outlives the items at all, see the comment on the // slot_scriptMainAreaClearHandlerSelection() call in dlgTriggerEditor::slot_scriptsSelected(). class ScriptEventHandlerLifetimeTest : public QObject { Q_OBJECT private: TelnetServerStub* mpServer = nullptr; dlgTriggerEditor* mpEditor = nullptr; Host* mpHost = nullptr; const QString mProfileName = qsl("ScriptEventHandlerLifetime-Test-Profile"); QString mPort; const QString mLocalhost = qsl("localhost"); void deleteProfileDirectory(const QString& profileName) { const QString path = mudlet::getMudletPath(enums::profileHomePath, profileName); QDir dir(path); if (dir.exists()) { dir.removeRecursively(); } } void startProfile(const QString& profileName, const QString& address, const QString& port) { QTimer::singleShot(0ms, qApp, [profileName, address, port]() { mudlet::self()->startAutoLogin({}); QTest::qWait(100ms); Q_ASSERT_X(mudlet::self()->mpConnectionDialog, "startProfile", "Connection dialog not initialized"); Q_ASSERT_X(mudlet::self()->mpConnectionDialog->new_profile_button, "startProfile", "New profile button not found"); QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton); QTest::qWait(100ms); Q_ASSERT_X(QApplication::focusWidget(), "startProfile", "No widget has focus after clicking new profile button"); QTest::keyClicks(QApplication::focusWidget(), profileName); QTest::qWait(100ms); QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); QTest::qWait(100ms); QTest::keyClicks(QApplication::focusWidget(), address); QTest::qWait(100ms); QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); QTest::qWait(100ms); QTest::keyClicks(QApplication::focusWidget(), port); QTest::qWait(100ms); QTest::keyClick(QApplication::focusWidget(), Qt::Key_Return); }); QSignalSpy spy(mudlet::self(), &mudlet::signal_profileLoaded); if (!spy.wait(5000)) { QFAIL("Profile took too long to load."); } mpHost = mudlet::self()->getActiveHost(); if (!mpHost) { QFAIL("No active host available for the test."); } QSignalSpy spy2(&(mpHost->mTelnet), &cTelnet::signal_connected); if (!spy2.wait(2000)) { QFAIL("Could not connect with the host."); } } QListWidget* handlerList() const { return mpEditor->mpScriptsMainArea->listWidget_script_registered_event_handlers; } QLineEdit* handlerEntry() const { return mpEditor->mpScriptsMainArea->lineEdit_script_event_handler_entry; } QStringList savedHandlersOf(QTreeWidgetItem* pTreeItem) const { if (!pTreeItem) { return {}; } TScript* pScript = mpHost->getScriptUnit()->getScript(pTreeItem->data(0, Qt::UserRole).toInt()); return pScript ? pScript->getEventHandlerList() : QStringList{}; } QTreeWidgetItem* addSavedScript(const QString& name, const QStringList& handlers) { mpEditor->treeWidget_scripts->setCurrentItem(mpEditor->mpScriptsBaseItem); mpEditor->addScript(false); QTest::qWait(50ms); QTreeWidgetItem* pTreeItem = mpEditor->mpCurrentScriptItem; if (!pTreeItem) { QTest::qFail("addScript() left no current script item", __FILE__, __LINE__); return nullptr; } mpEditor->mpScriptsMainArea->lineEdit_script_name->setText(name); for (const QString& handler : handlers) { handlerEntry()->setText(handler); mpEditor->slot_scriptMainAreaAddHandler(); } if (handlerList()->count() != handlers.count()) { QTest::qFail("the handlers did not all reach the Registered Events list", __FILE__, __LINE__); return nullptr; } mpEditor->slot_saveSelectedItem(); QTest::qWait(50ms); return pTreeItem; } QTreeWidgetItem* findEventHandlerSearchResult() const { QList pending; for (int i = 0; i < mpEditor->treeWidget_searchResults->topLevelItemCount(); ++i) { pending.append(mpEditor->treeWidget_searchResults->topLevelItem(i)); } while (!pending.isEmpty()) { QTreeWidgetItem* pResult = pending.takeFirst(); if (pResult->data(0, dlgTriggerEditor::TypeRole).toInt() == dlgTriggerEditor::SearchResultIsEventHandler) { return pResult; } for (int i = 0; i < pResult->childCount(); ++i) { pending.append(pResult->child(i)); } } return nullptr; } void removeScripts(const QList& treeItems) { for (QTreeWidgetItem* pTreeItem : treeItems) { mpEditor->treeWidget_scripts->setCurrentItem(pTreeItem); mpEditor->slot_deleteItemOrGroup(); QTest::qWait(20ms); } mpEditor->mpUndoStack->clear(); } private slots: void initTestCase() { initializeQRCResources(); mpServer = new TelnetServerStub(qApp); mpServer->start(mLocalhost, 0); // ephemeral OS-assigned port avoids collisions across concurrent test runs QVERIFY2(mpServer->isListening(), qPrintable(qsl("TelnetServerStub failed to start: %1").arg(mpServer->errorString()))); mPort = QString::number(mpServer->serverPort()); mudlet::start(); mudlet::self()->setupConfig(); mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique("MudletInstanceCoordinator")); mudlet::self()->init(); mudlet::self()->setStorePasswordsSecurely(false); deleteProfileDirectory(mProfileName); startProfile(mProfileName, mLocalhost, mPort); mudlet::self()->slot_showScriptDialog(); QTest::qWait(200ms); mpEditor = mpHost->mpEditorDialog; QVERIFY2(mpEditor != nullptr, "Editor dialog should be created"); mpEditor->slot_showScripts(); QTest::qWait(100ms); } void init() { if (!mpEditor) { QFAIL("the editor was never created, the profile setup must have failed"); } } void cleanupTestCase() { mpEditor = nullptr; mpHost = nullptr; delete mpServer; mpServer = nullptr; deleteProfileDirectory(mProfileName); delete mudlet::self(); } void testSwitchingScriptsDropsTheNotedHandler() { QTreeWidgetItem* pScriptA = addSavedScript(qsl("ScriptA"), {qsl("myTestEvent")}); QTreeWidgetItem* pScriptB = addSavedScript(qsl("ScriptB"), {}); mpEditor->treeWidget_scripts->setCurrentItem(pScriptA); QTest::qWait(50ms); QCOMPARE(handlerList()->count(), 1); // same signal path as a real click on the entry handlerList()->setCurrentRow(0); QTest::qWait(50ms); QCOMPARE(mpEditor->mpScriptsMainAreaEditHandlerItem, handlerList()->item(0)); QCOMPARE(mpEditor->mIsScriptsMainAreaEditHandler, true); QCOMPARE(handlerEntry()->text(), qsl("myTestEvent")); mpEditor->treeWidget_scripts->setCurrentItem(pScriptB); QTest::qWait(50ms); QCOMPARE(mpEditor->mpScriptsMainAreaEditHandlerItem, nullptr); QCOMPARE(mpEditor->mIsScriptsMainAreaEditHandler, false); QCOMPARE(handlerEntry()->text(), QString()); handlerEntry()->setText(qsl("otherEvent")); mpEditor->slot_scriptMainAreaAddHandler(); mpEditor->slot_saveSelectedItem(); QTest::qWait(50ms); QCOMPARE(savedHandlersOf(pScriptB), QStringList{qsl("otherEvent")}); QCOMPARE(savedHandlersOf(pScriptA), QStringList{qsl("myTestEvent")}); removeScripts({pScriptA, pScriptB}); } // One click on a tree entry emits itemSelectionChanged and then itemClicked, so // slot_scriptsSelected() runs twice and the second run, on the already-current item, // is an easy-to-miss second teardown. The replacement item can land on the freed one, // in which case "+" silently renames it instead of crashing. void testReselectingTheSameScriptDropsTheNotedHandler() { QTreeWidgetItem* pScript = addSavedScript(qsl("SoloScript"), {qsl("myTestEvent")}); mpEditor->treeWidget_scripts->setCurrentItem(pScript); QTest::qWait(50ms); QCOMPARE(handlerList()->count(), 1); handlerList()->setCurrentRow(0); QTest::qWait(50ms); QCOMPARE(mpEditor->mpScriptsMainAreaEditHandlerItem, handlerList()->item(0)); emit mpEditor->treeWidget_scripts->itemClicked(pScript, 0); QTest::qWait(50ms); QCOMPARE(mpEditor->mpScriptsMainAreaEditHandlerItem, nullptr); QCOMPARE(mpEditor->mIsScriptsMainAreaEditHandler, false); handlerEntry()->setText(qsl("secondEvent")); mpEditor->slot_scriptMainAreaAddHandler(); mpEditor->slot_saveSelectedItem(); QTest::qWait(50ms); QCOMPARE(savedHandlersOf(pScript), (QStringList{qsl("myTestEvent"), qsl("secondEvent")})); removeScripts({pScript}); } // Dropping the note takes the "Add User Event" text with it, so a half-typed name // cannot follow the user to the next script and land on that one instead. void testTypedButUnaddedTextDoesNotFollowToTheNextScript() { QTreeWidgetItem* pScriptA = addSavedScript(qsl("TypedTextA"), {}); QTreeWidgetItem* pScriptB = addSavedScript(qsl("TypedTextB"), {}); mpEditor->treeWidget_scripts->setCurrentItem(pScriptA); QTest::qWait(50ms); handlerEntry()->setText(qsl("neverAddedEvent")); mpEditor->treeWidget_scripts->setCurrentItem(pScriptB); QTest::qWait(50ms); QCOMPARE(handlerEntry()->text(), QString()); mpEditor->slot_scriptMainAreaAddHandler(); mpEditor->slot_saveSelectedItem(); QTest::qWait(50ms); QCOMPARE(savedHandlersOf(pScriptB), QStringList{}); QCOMPARE(savedHandlersOf(pScriptA), QStringList{}); removeScripts({pScriptA, pScriptB}); } // addScript() points mpCurrentScriptItem at the new script before selecting it, so // that selection skips the save as well while still tearing the list down. void testAddingAScriptDropsTheNotedHandler() { QTreeWidgetItem* pScript = addSavedScript(qsl("BeforeNewScript"), {qsl("myTestEvent")}); mpEditor->treeWidget_scripts->setCurrentItem(pScript); QTest::qWait(50ms); handlerList()->setCurrentRow(0); QTest::qWait(50ms); QCOMPARE(mpEditor->mpScriptsMainAreaEditHandlerItem, handlerList()->item(0)); mpEditor->addScript(false); QTest::qWait(50ms); QTreeWidgetItem* pNewScript = mpEditor->mpCurrentScriptItem; QVERIFY(pNewScript != pScript); QCOMPARE(mpEditor->mpScriptsMainAreaEditHandlerItem, nullptr); QCOMPARE(mpEditor->mIsScriptsMainAreaEditHandler, false); mpEditor->mpScriptsMainArea->lineEdit_script_name->setText(qsl("AfterNewScript")); handlerEntry()->setText(qsl("brandNewEvent")); mpEditor->slot_scriptMainAreaAddHandler(); mpEditor->slot_saveSelectedItem(); QTest::qWait(50ms); QCOMPARE(savedHandlersOf(pNewScript), QStringList{qsl("brandNewEvent")}); QCOMPARE(savedHandlersOf(pScript), QStringList{qsl("myTestEvent")}); removeScripts({pScript, pNewScript}); } // Nothing tears the list down between selecting the entry and pressing "+", so the // note has to survive here - guards against dropping it too eagerly. void testRenamingASelectedHandlerStillWorks() { QTreeWidgetItem* pScript = addSavedScript(qsl("RenameScript"), {qsl("firstEvent")}); mpEditor->treeWidget_scripts->setCurrentItem(pScript); QTest::qWait(50ms); handlerList()->setCurrentRow(0); QTest::qWait(50ms); handlerEntry()->setText(qsl("renamedEvent")); mpEditor->slot_scriptMainAreaAddHandler(); mpEditor->slot_saveSelectedItem(); QTest::qWait(50ms); QCOMPARE(savedHandlersOf(pScript), QStringList{qsl("renamedEvent")}); removeScripts({pScript}); } // The "-" button takes the row out of the list widget, which hands its // ownership over, so this also holds the leak checker over that path. void testDeletingAHandlerReleasesIt() { QTreeWidgetItem* pScript = addSavedScript(qsl("DeleteScript"), {qsl("firstEvent"), qsl("secondEvent")}); mpEditor->treeWidget_scripts->setCurrentItem(pScript); QTest::qWait(50ms); QCOMPARE(handlerList()->count(), 2); handlerList()->setCurrentRow(0); QTest::qWait(50ms); mpEditor->slot_scriptMainAreaDeleteHandler(); QTest::qWait(50ms); QCOMPARE(handlerList()->count(), 1); QCOMPARE(handlerList()->item(0)->text(), qsl("secondEvent")); QCOMPARE(mpEditor->mpScriptsMainAreaEditHandlerItem, nullptr); QCOMPARE(mpEditor->mIsScriptsMainAreaEditHandler, false); mpEditor->slot_saveSelectedItem(); QTest::qWait(50ms); QCOMPARE(savedHandlersOf(pScript), QStringList{qsl("secondEvent")}); removeScripts({pScript}); } // slot_itemSelectedInSearchResults() notes the item by hand rather than through the // list widget's selection, and only after every teardown of the list - so that note // has to survive, and the next script selection has to drop it. void testSearchResultNotesALiveHandler() { QTreeWidgetItem* pScriptA = addSavedScript(qsl("SearchScript"), {qsl("searchableEvent")}); QTreeWidgetItem* pScriptB = addSavedScript(qsl("OtherScript"), {}); mpEditor->treeWidget_scripts->setCurrentItem(pScriptB); QTest::qWait(50ms); mpEditor->comboBox_searchTerms->insertItem(0, qsl("searchableEvent")); mpEditor->slot_searchMudletItems(0); QTest::qWait(50ms); QTreeWidgetItem* pResult = findEventHandlerSearchResult(); QVERIFY2(pResult != nullptr, "the search found no event handler result to jump to"); mpEditor->slot_itemSelectedInSearchResults(pResult); QTest::qWait(50ms); QCOMPARE(mpEditor->mpCurrentScriptItem, pScriptA); QCOMPARE(handlerList()->count(), 1); QCOMPARE(mpEditor->mpScriptsMainAreaEditHandlerItem, handlerList()->item(0)); QCOMPARE(mpEditor->mIsScriptsMainAreaEditHandler, true); QCOMPARE(handlerEntry()->text(), qsl("searchableEvent")); mpEditor->treeWidget_scripts->setCurrentItem(pScriptB); QTest::qWait(50ms); QCOMPARE(mpEditor->mpScriptsMainAreaEditHandlerItem, nullptr); QCOMPARE(mpEditor->mIsScriptsMainAreaEditHandler, false); handlerEntry()->setText(qsl("afterSearchEvent")); mpEditor->slot_scriptMainAreaAddHandler(); mpEditor->slot_saveSelectedItem(); QTest::qWait(50ms); QCOMPARE(savedHandlersOf(pScriptB), QStringList{qsl("afterSearchEvent")}); QCOMPARE(savedHandlersOf(pScriptA), QStringList{qsl("searchableEvent")}); removeScripts({pScriptA, pScriptB}); } }; #include "ScriptEventHandlerLifetimeTest.moc" QTEST_MAIN(ScriptEventHandlerLifetimeTest)