mudlet/test/functional_tests/dlgTriggerEditorUndoRedoTest.cpp
Vadim Peretokin 2be2a6a5c2
fix: flag invalid aliases and block loops while typing them (#9499)
#### Brief overview of PR changes/additions

While editing an alias, Mudlet autosaves each field as you finish it
(the per-property "autosave" path, separate from the explicit Save
button). That path was missing two safety checks the Save button already
performs:

- **Invalid patterns were stored silently.** Typing a pattern that fails
to compile left no error icon and no message, so a broken alias was
saved with no feedback. Autosave now flags it with the error icon and
shows the same faulty-regex message as Save.
- **Infinite-loop aliases were accepted.** An alias whose command
matches its own pattern calls itself forever. Save rejects this;
autosave did not. The loop guard now runs on both the pattern and the
command field.

Shared logic between the two paths was factored into small helpers
(`aliasSubstitutionLoops`, `computeAliasIcon`, `setAliasNormalIcon`,
`showAliasError`, `showAliasLoopWarning`, `applyAliasState`) so the
autosave and explicit-save paths stay in sync. A freshly added alias
keeps its "unsaved" cue until an explicit Save, so autosave no longer
changes its activation state.

Added four functional tests to `dlgTriggerEditorUndoRedoTest` covering:
invalid-regex flagging and recovery, loop rejection from both the
command and the pattern field, and error clearing after a fix.

#### Motivation for adding to Mudlet

Users typing an invalid or self-looping alias got no feedback and ended
up with a broken, silently-stored alias. This brings the as-you-type
autosave to parity with the Save button.

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

Relates to #8469.

Assisted-by: Claude:claude-opus-4-8
2026-07-27 20:10:18 +02:00

2401 lines
82 KiB
C++

/***************************************************************************
* Copyright (C) 2025 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 <chrono>
#include "EditorUndoStack.h"
#include "Host.h"
#include "MudletInstanceCoordinator.h"
#include "TAction.h"
#include "TAlias.h"
#include "TKey.h"
#include "TTimer.h"
#include "TTreeWidget.h"
#include "TTrigger.h"
#include "TelnetServerStub.h"
#include "ctelnet.h"
#include "dlgActionMainArea.h"
#include "dlgAliasMainArea.h"
#include "dlgConnectionProfiles.h"
#include "dlgTimersMainArea.h"
#include "dlgTriggerEditor.h"
#include "dlgTriggerPatternEdit.h"
#include "dlgTriggersMainArea.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();
}
class dlgTriggerEditorUndoRedoTest : public QObject {
Q_OBJECT
private:
TelnetServerStub *mpServer = nullptr;
dlgTriggerEditor *mpEditor = nullptr;
Host *mpHost = nullptr;
const QString mProfileName = qsl("UndoRedo-Test-Profile");
QString mPort; // assigned the stub's actual ephemeral port in initTestCase()
const QString mLocalhost = qsl("localhost");
struct ItemTypeInfo {
QString name;
EditorViewType viewType;
std::function<void()> showView;
std::function<void()> addItem;
std::function<void()> addFolder;
std::function<QTreeWidgetItem *()> getBaseItem;
std::function<TTreeWidget *()> getTreeWidget;
QString newItemText;
QString newFolderText;
QTreeWidgetItem *baseItem() const { return getBaseItem(); }
TTreeWidget *treeWidget() const { return getTreeWidget(); }
};
std::vector<ItemTypeInfo> mItemTypes;
void cleanupAll(const ItemTypeInfo &itemType) {
itemType.treeWidget()->clearSelection();
itemType.treeWidget()->setCurrentItem(nullptr);
QCoreApplication::processEvents();
while (itemType.baseItem()->childCount() > 0) {
itemType.treeWidget()->setCurrentItem(itemType.baseItem()->child(0));
mpEditor->slot_deleteItemOrGroup();
}
mpEditor->mpUndoStack->clear();
}
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);
// Verify connection dialog is available before UI interactions
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(2000)) {
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(1000)) {
QFAIL("Could not connect with the host.");
}
}
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>(
"MudletInstanceCoordinator"));
mudlet::self()->init();
mudlet::self()->setStorePasswordsSecurely(false);
deleteProfileDirectory(mProfileName);
startProfile(mProfileName, mLocalhost, mPort);
// Open the editor dialog (it's created lazily)
mudlet::self()->slot_showScriptDialog();
QTest::qWait(100ms);
mpEditor = mpHost->mpEditorDialog;
QVERIFY2(mpEditor != nullptr, "Editor dialog should be created");
QVERIFY2(mpEditor->mpUndoStack != nullptr, "Undo stack should exist");
mItemTypes = {{qsl("Trigger"), EditorViewType::cmTriggerView,
[this]() { mpEditor->slot_showTriggers(); },
[this]() { mpEditor->addTrigger(false); },
[this]() { mpEditor->addTrigger(true); },
[this]() { return mpEditor->mpTriggerBaseItem; },
[this]() { return mpEditor->treeWidget_triggers; },
qsl("New trigger"), qsl("New trigger group")},
{qsl("Timer"), EditorViewType::cmTimerView,
[this]() { mpEditor->slot_showTimers(); },
[this]() { mpEditor->addTimer(false); },
[this]() { mpEditor->addTimer(true); },
[this]() { return mpEditor->mpTimerBaseItem; },
[this]() { return mpEditor->treeWidget_timers; },
qsl("New timer"), qsl("New timer group")},
{qsl("Alias"), EditorViewType::cmAliasView,
[this]() { mpEditor->slot_showAliases(); },
[this]() { mpEditor->addAlias(false); },
[this]() { mpEditor->addAlias(true); },
[this]() { return mpEditor->mpAliasBaseItem; },
[this]() { return mpEditor->treeWidget_aliases; },
qsl("New alias"), qsl("New alias group")},
{qsl("Script"), EditorViewType::cmScriptView,
[this]() { mpEditor->slot_showScripts(); },
[this]() { mpEditor->addScript(false); },
[this]() { mpEditor->addScript(true); },
[this]() { return mpEditor->mpScriptsBaseItem; },
[this]() { return mpEditor->treeWidget_scripts; },
qsl("New script"), qsl("New script group")},
{qsl("Key"), EditorViewType::cmKeysView,
[this]() { mpEditor->slot_showKeys(); },
[this]() { mpEditor->addKey(false); },
[this]() { mpEditor->addKey(true); },
[this]() { return mpEditor->mpKeyBaseItem; },
[this]() { return mpEditor->treeWidget_keys; },
qsl("New key"), qsl("New key group")},
{qsl("Action"), EditorViewType::cmActionView,
[this]() { mpEditor->slot_showActions(); },
[this]() { mpEditor->addAction(false); },
[this]() { mpEditor->addAction(true); },
[this]() { return mpEditor->mpActionBaseItem; },
[this]() { return mpEditor->treeWidget_actions; },
qsl("New button"), qsl("New button group")}};
}
void cleanupTestCase() {
mItemTypes.clear();
mpEditor = nullptr;
mpHost = nullptr;
delete mpServer;
mpServer = nullptr;
deleteProfileDirectory(mProfileName);
delete mudlet::self();
}
// ========================================================================
// CATEGORY 1: Core Operations - Single Items
// ========================================================================
void testCoreOperations_data() {
QTest::addColumn<int>("itemTypeIndex");
QTest::addColumn<QString>("itemTypeName");
QTest::newRow("Trigger") << 0 << "Trigger";
QTest::newRow("Timer") << 1 << "Timer";
QTest::newRow("Alias") << 2 << "Alias";
QTest::newRow("Script") << 3 << "Script";
QTest::newRow("Key") << 4 << "Key";
QTest::newRow("Action") << 5 << "Action";
}
void testCoreOperations() {
QFETCH(int, itemTypeIndex);
QFETCH(QString, itemTypeName);
const auto &itemType = mItemTypes[itemTypeIndex];
itemType.showView();
cleanupAll(itemType);
// Test: Add item → undo → redo
{
int initialCount = itemType.baseItem()->childCount();
itemType.addItem();
QVERIFY2(itemType.baseItem()->childCount() > initialCount,
qPrintable(itemTypeName + ": Item should be added"));
mpEditor->mpUndoStack->undo();
QCOMPARE(itemType.baseItem()->childCount(), initialCount);
mpEditor->mpUndoStack->redo();
QVERIFY2(
itemType.baseItem()->childCount() > initialCount,
qPrintable(itemTypeName + ": Item should be restored after redo"));
mpEditor->mpUndoStack->undo();
}
// Test: Add folder → undo → redo
{
int initialCount = itemType.baseItem()->childCount();
itemType.addFolder();
QVERIFY2(itemType.baseItem()->childCount() > initialCount,
qPrintable(itemTypeName + ": Folder should be added"));
mpEditor->mpUndoStack->undo();
QCOMPARE(itemType.baseItem()->childCount(), initialCount);
mpEditor->mpUndoStack->redo();
QVERIFY2(
itemType.baseItem()->childCount() > initialCount,
qPrintable(itemTypeName + ": Folder should be restored after redo"));
mpEditor->mpUndoStack->undo();
}
// Test: Delete item → undo → redo
{
itemType.addItem();
QTreeWidgetItem *item = itemType.baseItem()->child(0);
QVERIFY(item != nullptr);
itemType.treeWidget()->setCurrentItem(item);
int countBeforeDelete = itemType.baseItem()->childCount();
mpEditor->slot_deleteItemOrGroup();
QVERIFY2(itemType.baseItem()->childCount() < countBeforeDelete,
qPrintable(itemTypeName + ": Item should be deleted"));
mpEditor->mpUndoStack->undo();
QCOMPARE(itemType.baseItem()->childCount(), countBeforeDelete);
mpEditor->mpUndoStack->redo();
QVERIFY2(itemType.baseItem()->childCount() < countBeforeDelete,
qPrintable(itemTypeName + ": Delete should be redone"));
}
// Test: Delete empty folder → undo → redo
{
itemType.addFolder();
QTreeWidgetItem *folder = itemType.baseItem()->child(0);
QVERIFY(folder != nullptr);
itemType.treeWidget()->setCurrentItem(folder);
int countBeforeDelete = itemType.baseItem()->childCount();
mpEditor->slot_deleteItemOrGroup();
QVERIFY2(itemType.baseItem()->childCount() < countBeforeDelete,
qPrintable(itemTypeName + ": Empty folder should be deleted"));
mpEditor->mpUndoStack->undo();
QCOMPARE(itemType.baseItem()->childCount(), countBeforeDelete);
mpEditor->mpUndoStack->redo();
QVERIFY2(
itemType.baseItem()->childCount() < countBeforeDelete,
qPrintable(itemTypeName + ": Empty folder delete should be redone"));
}
mpEditor->mpUndoStack->clear();
}
// ========================================================================
// CATEGORY 2: Parent-Only Selection
// ========================================================================
void testParentOnlySelection_data() {
QTest::addColumn<int>("itemTypeIndex");
QTest::addColumn<QString>("itemTypeName");
QTest::newRow("Trigger") << 0 << "Trigger";
QTest::newRow("Timer") << 1 << "Timer";
QTest::newRow("Alias") << 2 << "Alias";
QTest::newRow("Script") << 3 << "Script";
QTest::newRow("Key") << 4 << "Key";
QTest::newRow("Action") << 5 << "Action";
}
void testParentOnlySelection() {
QFETCH(int, itemTypeIndex);
QFETCH(QString, itemTypeName);
const auto &itemType = mItemTypes[itemTypeIndex];
itemType.showView();
cleanupAll(itemType);
// Test: Delete parent with children → undo
{
itemType.addFolder();
if (itemType.viewType == EditorViewType::cmKeysView ||
itemType.viewType == EditorViewType::cmActionView) {
QCoreApplication::processEvents();
QThread::sleep(10ms);
}
QTreeWidgetItem *folder = itemType.baseItem()->child(0);
QVERIFY2(folder != nullptr,
qPrintable(itemTypeName + ": Folder should be created"));
itemType.treeWidget()->setCurrentItem(folder);
itemType.addItem();
if (itemType.viewType == EditorViewType::cmKeysView ||
itemType.viewType == EditorViewType::cmActionView) {
QCoreApplication::processEvents();
}
itemType.treeWidget()->setCurrentItem(folder);
itemType.addItem();
if (itemType.viewType == EditorViewType::cmKeysView ||
itemType.viewType == EditorViewType::cmActionView) {
QCoreApplication::processEvents();
}
QCOMPARE(folder->childCount(), 2);
itemType.treeWidget()->setCurrentItem(folder);
int totalCountBefore = itemType.baseItem()->childCount();
mpEditor->slot_deleteItemOrGroup();
QVERIFY2(itemType.baseItem()->childCount() < totalCountBefore,
qPrintable(itemTypeName +
": Parent with children should be deleted"));
mpEditor->mpUndoStack->undo();
QTreeWidgetItem *restoredFolder = nullptr;
for (int i = 0; i < itemType.baseItem()->childCount(); i++) {
QTreeWidgetItem *item = itemType.baseItem()->child(i);
if (item->childCount() == 2) {
restoredFolder = item;
break;
}
}
QVERIFY2(restoredFolder != nullptr && restoredFolder->childCount() == 2,
qPrintable(itemTypeName +
": Parent and children should be restored"));
cleanupAll(itemType);
}
// Test: Toggle parent off with active children → undo
{
itemType.addFolder();
if (itemType.viewType == EditorViewType::cmKeysView ||
itemType.viewType == EditorViewType::cmActionView) {
QCoreApplication::processEvents();
QThread::sleep(10ms);
}
QTreeWidgetItem *folder = itemType.baseItem()->child(0);
QVERIFY2(folder != nullptr,
qPrintable(itemTypeName +
": Folder should be created for toggle test"));
itemType.treeWidget()->setCurrentItem(folder);
itemType.addItem();
if (itemType.viewType == EditorViewType::cmKeysView ||
itemType.viewType == EditorViewType::cmActionView) {
QCoreApplication::processEvents();
}
QCOMPARE(folder->childCount(), 1);
QTreeWidgetItem *child = folder->child(0);
// Activate parent and child
itemType.treeWidget()->setCurrentItem(folder);
mpEditor->slot_toggleItemOrGroupActiveFlag();
itemType.treeWidget()->setCurrentItem(child);
mpEditor->slot_toggleItemOrGroupActiveFlag();
// Now toggle parent off
itemType.treeWidget()->setCurrentItem(folder);
mpEditor->slot_toggleItemOrGroupActiveFlag();
// Undo toggle
mpEditor->mpUndoStack->undo();
QVERIFY2(true, qPrintable(itemTypeName + ": Toggle undo should work"));
cleanupAll(itemType);
}
// Test: Multi-level hierarchy delete (grandparent -> parent -> child)
{
int initialCount = itemType.baseItem()->childCount();
// Create grandparent folder at root
itemType.treeWidget()->setCurrentItem(itemType.baseItem());
itemType.addFolder();
if (itemType.viewType == EditorViewType::cmKeysView ||
itemType.viewType == EditorViewType::cmActionView) {
QCoreApplication::processEvents();
QThread::sleep(10ms);
}
QTreeWidgetItem *grandparent = itemType.baseItem()->child(0);
QVERIFY2(
grandparent != nullptr,
qPrintable(itemTypeName + ": Grandparent folder should be created"));
// Add parent folder under grandparent
itemType.treeWidget()->setCurrentItem(grandparent);
itemType.addFolder();
if (itemType.viewType == EditorViewType::cmKeysView ||
itemType.viewType == EditorViewType::cmActionView) {
QCoreApplication::processEvents();
}
QTreeWidgetItem *parent = grandparent->child(0);
QVERIFY2(parent != nullptr,
qPrintable(itemTypeName + ": Parent folder should be created"));
// Add child under parent
itemType.treeWidget()->setCurrentItem(parent);
itemType.addItem();
if (itemType.viewType == EditorViewType::cmKeysView ||
itemType.viewType == EditorViewType::cmActionView) {
QCoreApplication::processEvents();
}
QCOMPARE(parent->childCount(), 1);
// Delete grandparent (should delete 2 levels of children)
itemType.treeWidget()->setCurrentItem(grandparent);
mpEditor->slot_deleteItemOrGroup();
QCOMPARE(itemType.baseItem()->childCount(), initialCount);
mpEditor->mpUndoStack->undo();
// Check if entire hierarchy restored
QTreeWidgetItem *restoredGP = itemType.baseItem()->child(0);
QVERIFY2(restoredGP != nullptr && restoredGP->childCount() == 1,
qPrintable(itemTypeName +
": Grandparent with parent should be restored"));
QTreeWidgetItem *restoredP = restoredGP->child(0);
QVERIFY2(
restoredP != nullptr && restoredP->childCount() == 1,
qPrintable(itemTypeName + ": Parent with child should be restored"));
cleanupAll(itemType);
}
mpEditor->mpUndoStack->clear();
}
// ========================================================================
// CATEGORY 3: Parent + All Children Selected (Multi-selection)
// ========================================================================
void testMultiSelection_data() {
QTest::addColumn<int>("itemTypeIndex");
QTest::addColumn<QString>("itemTypeName");
QTest::newRow("Trigger") << 0 << "Trigger";
QTest::newRow("Timer") << 1 << "Timer";
QTest::newRow("Alias") << 2 << "Alias";
QTest::newRow("Script") << 3 << "Script";
QTest::newRow("Key") << 4 << "Key";
QTest::newRow("Action") << 5 << "Action";
}
void testMultiSelection() {
QFETCH(int, itemTypeIndex);
QFETCH(QString, itemTypeName);
const auto &itemType = mItemTypes[itemTypeIndex];
itemType.showView();
cleanupAll(itemType);
// Test: Delete parent+children selected → verify single operation
{
itemType.treeWidget()->setCurrentItem(itemType.baseItem());
itemType.addFolder();
QTreeWidgetItem *folder = itemType.baseItem()->child(0);
QVERIFY(folder != nullptr);
itemType.treeWidget()->setCurrentItem(folder);
itemType.addItem();
itemType.treeWidget()->setCurrentItem(folder);
itemType.addItem();
QTreeWidgetItem *child1 = folder->child(0);
QTreeWidgetItem *child2 = folder->child(1);
QVERIFY2(folder->childCount() == 2 && child1 && child2 &&
child1 != child2,
qPrintable(itemTypeName + ": Should have 2 distinct children"));
QList<QTreeWidgetItem *> items;
items << folder << child1 << child2;
itemType.treeWidget()->clearSelection();
for (auto *item : std::as_const(items)) {
item->setSelected(true);
}
itemType.treeWidget()->setCurrentItem(folder);
int stackCountBefore = mpEditor->mpUndoStack->count();
mpEditor->slot_deleteItemOrGroup();
int stackCountAfter = mpEditor->mpUndoStack->count();
QVERIFY2(
stackCountAfter <= stackCountBefore + 1,
qPrintable(
itemTypeName +
": Delete parent+children should create single/batched command"));
mpEditor->mpUndoStack->undo();
QTreeWidgetItem *restored = itemType.baseItem()->child(0);
QVERIFY2(restored != nullptr && restored->childCount() == 2,
qPrintable(itemTypeName + ": Single undo should restore all"));
cleanupAll(itemType);
}
// Test: Toggle parent+children selected → verify single operation
{
itemType.addFolder();
QTreeWidgetItem *folder = itemType.baseItem()->child(0);
QVERIFY(folder != nullptr);
itemType.treeWidget()->setCurrentItem(folder);
itemType.addItem();
QCOMPARE(folder->childCount(), 1);
// Select all items (parent and child)
QList<QTreeWidgetItem *> items;
items << folder << folder->child(0);
itemType.treeWidget()->clearSelection();
for (auto *item : std::as_const(items)) {
item->setSelected(true);
}
itemType.treeWidget()->setCurrentItem(folder);
int stackCountBefore = mpEditor->mpUndoStack->count();
mpEditor->slot_toggleItemOrGroupActiveFlag();
int stackCountAfter = mpEditor->mpUndoStack->count();
// Should be batched (single command or small increment)
QVERIFY2(
stackCountAfter <= stackCountBefore + 1,
qPrintable(
itemTypeName +
": Toggle parent+children should create single/batched command"));
mpEditor->mpUndoStack->undo();
QVERIFY2(true, qPrintable(itemTypeName +
": Single undo should restore toggle state"));
cleanupAll(itemType);
}
mpEditor->mpUndoStack->clear();
}
// ========================================================================
// CATEGORY 4: ID Remapping
// ========================================================================
void testIdRemapping_data() {
QTest::addColumn<int>("itemTypeIndex");
QTest::addColumn<QString>("itemTypeName");
QTest::newRow("Trigger") << 0 << "Trigger";
QTest::newRow("Timer") << 1 << "Timer";
QTest::newRow("Alias") << 2 << "Alias";
QTest::newRow("Script") << 3 << "Script";
QTest::newRow("Key") << 4 << "Key";
QTest::newRow("Action") << 5 << "Action";
}
void testIdRemapping() {
QFETCH(int, itemTypeIndex);
QFETCH(QString, itemTypeName);
const auto &itemType = mItemTypes[itemTypeIndex];
itemType.showView();
cleanupAll(itemType);
// Test: Delete → undo (verify new ID assigned)
{
itemType.addItem();
QTreeWidgetItem *item = itemType.baseItem()->child(0);
QVERIFY(item != nullptr);
int originalID = item->data(0, Qt::UserRole).toInt();
itemType.treeWidget()->setCurrentItem(item);
mpEditor->slot_deleteItemOrGroup();
mpEditor->mpUndoStack->undo();
QTreeWidgetItem *restoredItem = itemType.baseItem()->child(0);
QVERIFY2(restoredItem != nullptr,
qPrintable(itemTypeName + ": Item should be restored"));
// ID may or may not be remapped depending on implementation
int newID = restoredItem->data(0, Qt::UserRole).toInt();
QVERIFY2(newID > 0, qPrintable(itemTypeName +
": Restored item should have valid ID"));
cleanupAll(itemType);
}
// Test: Delete → undo → redo → undo chain
{
itemType.addItem();
QTreeWidgetItem *item = itemType.baseItem()->child(0);
QVERIFY(item != nullptr);
itemType.treeWidget()->setCurrentItem(item);
mpEditor->slot_deleteItemOrGroup();
mpEditor->mpUndoStack->undo();
mpEditor->mpUndoStack->redo();
mpEditor->mpUndoStack->undo();
QVERIFY2(itemType.baseItem()->childCount() > 0,
qPrintable(itemTypeName + ": Undo/redo chain should work"));
cleanupAll(itemType);
}
// Test: Delete parent with children → verify all IDs remapped
{
itemType.addFolder();
QTreeWidgetItem *folder = itemType.baseItem()->child(0);
QVERIFY(folder != nullptr);
itemType.treeWidget()->setCurrentItem(folder);
itemType.addItem();
itemType.treeWidget()->setCurrentItem(folder);
itemType.addItem();
QCOMPARE(folder->childCount(), 2);
itemType.treeWidget()->setCurrentItem(folder);
mpEditor->slot_deleteItemOrGroup();
mpEditor->mpUndoStack->undo();
QTreeWidgetItem *restoredFolder = itemType.baseItem()->child(0);
QVERIFY2(restoredFolder != nullptr && restoredFolder->childCount() == 2,
qPrintable(itemTypeName +
": Parent and children IDs should be remapped"));
cleanupAll(itemType);
}
// Test: Commands in stack updated with new IDs
{
itemType.treeWidget()->setCurrentItem(itemType.baseItem());
itemType.addItem();
QTreeWidgetItem *item1 = itemType.baseItem()->child(0);
itemType.treeWidget()->setCurrentItem(itemType.baseItem());
itemType.addItem();
QVERIFY(itemType.baseItem()->childCount() >= 2);
QVERIFY(item1 != nullptr);
itemType.treeWidget()->setCurrentItem(item1);
mpEditor->slot_deleteItemOrGroup();
mpEditor->mpUndoStack->undo();
for (int i = 0; i < 10 && itemType.baseItem()->childCount() > 1 &&
mpEditor->mpUndoStack->canUndo();
i++) {
mpEditor->mpUndoStack->undo();
}
QVERIFY2(itemType.baseItem()->childCount() <= 1,
qPrintable(itemTypeName +
": Stack should handle ID remapping correctly"));
cleanupAll(itemType);
}
// Test: Multiple undo/redo cycles with nested hierarchy and moves
// (regression test for child ID remapping across cycles)
{
itemType.addFolder();
QTreeWidgetItem *parent = itemType.baseItem()->child(0);
QVERIFY(parent != nullptr);
// Add first child folder
itemType.treeWidget()->setCurrentItem(parent);
itemType.addFolder();
QCoreApplication::processEvents();
// Add second child folder that will have a grandchild
itemType.treeWidget()->setCurrentItem(parent);
itemType.addFolder();
QCoreApplication::processEvents();
QCOMPARE(parent->childCount(), 2);
QTreeWidgetItem *childWithGrandchild = parent->child(0);
// Add grandchild item under the child folder
itemType.treeWidget()->setCurrentItem(childWithGrandchild);
itemType.addItem();
QCoreApplication::processEvents();
QCOMPARE(childWithGrandchild->childCount(), 1);
// Move the grandchild to be a direct child of parent (simulate drag-drop)
QTreeWidgetItem *grandchild = childWithGrandchild->child(0);
int grandchildID = grandchild->data(0, Qt::UserRole).toInt();
int oldParentID = childWithGrandchild->data(0, Qt::UserRole).toInt();
int newParentID = parent->data(0, Qt::UserRole).toInt();
int oldPosition = 0;
childWithGrandchild->takeChild(0);
parent->addChild(grandchild);
int newPosition = parent->indexOfChild(grandchild);
mpEditor->slot_itemMoved(grandchildID, oldParentID, newParentID,
oldPosition, newPosition);
QVERIFY2(parent->childCount() == 3 &&
childWithGrandchild->childCount() == 0,
qPrintable(itemTypeName + ": Move operation should succeed"));
// Test 3 full undo/redo cycles
for (int cycle = 0; cycle < 3; cycle++) {
// Undo all operations
for (int i = 0; i < 10 && mpEditor->mpUndoStack->canUndo() &&
itemType.baseItem()->childCount() > 0;
i++) {
mpEditor->mpUndoStack->undo();
}
QVERIFY2(itemType.baseItem()->childCount() == 0,
qPrintable(itemTypeName +
": All items should be undone in cycle " +
QString::number(cycle)));
// Redo all operations
for (int i = 0; i < 10 && mpEditor->mpUndoStack->canRedo(); i++) {
mpEditor->mpUndoStack->redo();
}
QTreeWidgetItem *restoredParent = itemType.baseItem()->child(0);
QVERIFY2(
restoredParent != nullptr && restoredParent->childCount() == 3,
qPrintable(itemTypeName +
": Parent should have 3 children after redo in cycle " +
QString::number(cycle)));
// Verify moved grandchild is still direct child of parent
QTreeWidgetItem *childFolder = restoredParent->child(0);
QVERIFY2(
childFolder != nullptr && childFolder->childCount() == 0,
qPrintable(itemTypeName +
": Child folder should be empty (grandchild was moved) "
"in cycle " +
QString::number(cycle)));
}
cleanupAll(itemType);
}
mpEditor->mpUndoStack->clear();
}
// ========================================================================
// CATEGORY 5: Undo/Redo Chains
// ========================================================================
void testUndoRedoChains_data() {
QTest::addColumn<int>("itemTypeIndex");
QTest::addColumn<QString>("itemTypeName");
QTest::newRow("Trigger") << 0 << "Trigger";
QTest::newRow("Timer") << 1 << "Timer";
QTest::newRow("Alias") << 2 << "Alias";
QTest::newRow("Script") << 3 << "Script";
QTest::newRow("Key") << 4 << "Key";
QTest::newRow("Action") << 5 << "Action";
}
void testUndoRedoChains() {
QFETCH(int, itemTypeIndex);
QFETCH(QString, itemTypeName);
const auto &itemType = mItemTypes[itemTypeIndex];
itemType.showView();
cleanupAll(itemType);
// Test: 5 operations → undo all → redo all
{
int initialCount = itemType.baseItem()->childCount();
for (int i = 0; i < 5; i++) {
itemType.addItem();
}
QCOMPARE(itemType.baseItem()->childCount(), initialCount + 5);
for (int i = 0;
i < 50 && itemType.baseItem()->childCount() > initialCount &&
mpEditor->mpUndoStack->canUndo();
i++) {
mpEditor->mpUndoStack->undo();
}
QCOMPARE(itemType.baseItem()->childCount(), initialCount);
for (int i = 0; i < 50 && mpEditor->mpUndoStack->canRedo() &&
itemType.baseItem()->childCount() < initialCount + 5;
i++) {
mpEditor->mpUndoStack->redo();
}
QCOMPARE(itemType.baseItem()->childCount(), initialCount + 5);
cleanupAll(itemType);
}
// Test: Undo 3 times → redo 2 → new op clears redo
{
itemType.addItem();
itemType.addItem();
itemType.addItem();
mpEditor->mpUndoStack->undo();
mpEditor->mpUndoStack->undo();
mpEditor->mpUndoStack->undo();
mpEditor->mpUndoStack->redo();
mpEditor->mpUndoStack->redo();
bool canRedoBefore = mpEditor->mpUndoStack->canRedo();
itemType.addItem();
bool canRedoAfter = mpEditor->mpUndoStack->canRedo();
QVERIFY2(
canRedoBefore && !canRedoAfter,
qPrintable(itemTypeName + ": New operation should clear redo stack"));
cleanupAll(itemType);
}
// Test: Verify canUndo()/canRedo() states
{
itemType.addItem();
bool canUndoAfterAdd = mpEditor->mpUndoStack->canUndo();
mpEditor->mpUndoStack->undo();
bool canRedoAfterUndo = mpEditor->mpUndoStack->canRedo();
QVERIFY2(canUndoAfterAdd && canRedoAfterUndo,
qPrintable(itemTypeName +
": canUndo/canRedo states should be correct"));
cleanupAll(itemType);
}
// Test: Complex operation chain (Add → delete → add → undo → redo → undo)
{
int stackIndexStart = mpEditor->mpUndoStack->index();
itemType.addItem();
QTreeWidgetItem *item1 = itemType.baseItem()->child(0);
QVERIFY(item1 != nullptr);
itemType.treeWidget()->setCurrentItem(item1);
mpEditor->slot_deleteItemOrGroup();
itemType.addItem();
mpEditor->mpUndoStack->undo();
mpEditor->mpUndoStack->redo();
mpEditor->mpUndoStack->undo();
QVERIFY2(true,
qPrintable(itemTypeName + ": Complex operation chain works"));
while (mpEditor->mpUndoStack->index() > stackIndexStart) {
mpEditor->mpUndoStack->undo();
}
cleanupAll(itemType);
}
mpEditor->mpUndoStack->clear();
}
// ========================================================================
// CATEGORY 6: Edge Cases
// ========================================================================
void testEdgeCases_data() {
QTest::addColumn<int>("itemTypeIndex");
QTest::addColumn<QString>("itemTypeName");
QTest::newRow("Trigger") << 0 << "Trigger";
QTest::newRow("Timer") << 1 << "Timer";
QTest::newRow("Alias") << 2 << "Alias";
QTest::newRow("Script") << 3 << "Script";
QTest::newRow("Key") << 4 << "Key";
QTest::newRow("Action") << 5 << "Action";
}
void testEdgeCases() {
QFETCH(int, itemTypeIndex);
QFETCH(QString, itemTypeName);
const auto &itemType = mItemTypes[itemTypeIndex];
itemType.showView();
cleanupAll(itemType);
// Test: Undo when stack is empty
{
bool canUndoBefore = mpEditor->mpUndoStack->canUndo();
mpEditor->mpUndoStack->undo();
bool canUndoAfter = mpEditor->mpUndoStack->canUndo();
QVERIFY2(
!canUndoBefore && !canUndoAfter,
qPrintable(itemTypeName +
": Undo on empty stack should be handled gracefully"));
}
// Test: Redo when nothing to redo
{
bool canRedoBefore = mpEditor->mpUndoStack->canRedo();
mpEditor->mpUndoStack->redo();
bool canRedoAfter = mpEditor->mpUndoStack->canRedo();
QVERIFY2(!canRedoBefore && !canRedoAfter,
qPrintable(
itemTypeName +
": Redo with nothing to redo should be handled gracefully"));
}
// Test: Clear stack mid-operation
{
itemType.addItem();
itemType.addItem();
mpEditor->mpUndoStack->clear();
QVERIFY2(
!mpEditor->mpUndoStack->canUndo() &&
!mpEditor->mpUndoStack->canRedo(),
qPrintable(itemTypeName + ": Clear stack should work correctly"));
cleanupAll(itemType);
}
// Test: Operations work after stack clear
{
mpEditor->mpUndoStack->clear();
int initialCount = itemType.baseItem()->childCount();
itemType.addItem();
bool addWorked = itemType.baseItem()->childCount() > initialCount;
bool canUndoAfterAdd = mpEditor->mpUndoStack->canUndo();
QVERIFY2(addWorked && canUndoAfterAdd,
qPrintable(itemTypeName +
": Operations should work after stack clear"));
mpEditor->mpUndoStack->undo();
cleanupAll(itemType);
}
// Test: Deep nesting (10 levels)
{
QTreeWidgetItem *currentParent = itemType.baseItem();
for (int i = 0; i < 10; i++) {
itemType.treeWidget()->setCurrentItem(currentParent);
itemType.addFolder();
if (currentParent == itemType.baseItem()) {
currentParent = itemType.baseItem()->child(0);
} else {
currentParent = currentParent->child(0);
}
}
QTreeWidgetItem *deepest = itemType.baseItem();
int depth = 0;
while (deepest && deepest->childCount() > 0) {
deepest = deepest->child(0);
depth++;
}
QCOMPARE(depth, 10);
itemType.treeWidget()->setCurrentItem(itemType.baseItem()->child(0));
mpEditor->slot_deleteItemOrGroup();
mpEditor->mpUndoStack->undo();
QTreeWidgetItem *restored = itemType.baseItem();
int restoredDepth = 0;
while (restored && restored->childCount() > 0) {
restored = restored->child(0);
restoredDepth++;
}
QCOMPARE(restoredDepth, 10);
cleanupAll(itemType);
}
mpEditor->mpUndoStack->clear();
}
// ========================================================================
// CATEGORY 7: Integration Tests
// ========================================================================
void testIntegration() {
for (auto &itemType : mItemTypes) {
itemType.showView();
cleanupAll(itemType);
}
// Test: Mixed operations across item types
{
const auto &triggers = mItemTypes[0];
const auto &timers = mItemTypes[1];
triggers.showView();
triggers.addItem();
int triggerCountAfterAdd = triggers.baseItem()->childCount();
timers.showView();
timers.addItem();
for (int i = 0; i < 20 &&
(triggers.baseItem()->childCount() > 0 ||
timers.baseItem()->childCount() > 0) &&
mpEditor->mpUndoStack->canUndo();
i++) {
mpEditor->mpUndoStack->undo();
}
QVERIFY2(triggers.baseItem()->childCount() == 0 &&
timers.baseItem()->childCount() == 0,
"Mixed operations should undo correctly");
mpEditor->mpUndoStack->clear();
}
// Test: Cross-type undo/redo ordering
{
const auto &triggers = mItemTypes[0];
const auto &aliases = mItemTypes[2];
triggers.showView();
triggers.addItem();
aliases.showView();
aliases.addItem();
for (int i = 0; i < 10 && aliases.baseItem()->childCount() > 0 &&
mpEditor->mpUndoStack->canUndo();
i++) {
mpEditor->mpUndoStack->undo();
}
QVERIFY2(aliases.baseItem()->childCount() == 0 &&
triggers.baseItem()->childCount() == 1,
"Cross-type undo ordering should be correct");
for (int i = 0; i < 10 && aliases.baseItem()->childCount() == 0 &&
mpEditor->mpUndoStack->canRedo();
i++) {
mpEditor->mpUndoStack->redo();
}
QVERIFY2(aliases.baseItem()->childCount() == 1,
"Cross-type redo ordering should be correct");
for (int i = 0; i < 20 && mpEditor->mpUndoStack->canUndo(); i++) {
mpEditor->mpUndoStack->undo();
}
mpEditor->mpUndoStack->clear();
}
// Test: Undo works after view switch
{
const auto &triggers = mItemTypes[0];
const auto &scripts = mItemTypes[3];
triggers.showView();
triggers.addItem();
scripts.showView();
for (int i = 0; i < 10 && triggers.baseItem()->childCount() > 0 &&
mpEditor->mpUndoStack->canUndo();
i++) {
mpEditor->mpUndoStack->undo();
}
QVERIFY2(triggers.baseItem()->childCount() == 0,
"Undo should work after view switch");
mpEditor->mpUndoStack->clear();
}
// Test: Stack isolation verification
{
const auto &triggers = mItemTypes[0];
const auto &timers = mItemTypes[1];
triggers.showView();
int initialStackCount = mpEditor->mpUndoStack->count();
triggers.addItem();
int afterAddCount = mpEditor->mpUndoStack->count();
bool isolated = true;
for (const auto &otherType : mItemTypes) {
if (otherType.viewType != triggers.viewType &&
otherType.baseItem()->childCount() > 0) {
isolated = false;
break;
}
}
QVERIFY2(isolated && afterAddCount > initialStackCount,
"Stack should be properly isolated between types");
mpEditor->mpUndoStack->undo();
mpEditor->mpUndoStack->clear();
}
// Test: Sequential delete operations across types
const auto &scripts = mItemTypes[3];
const auto &aliases = mItemTypes[2];
const auto &triggers = mItemTypes[0];
scripts.showView();
scripts.addItem();
aliases.showView();
aliases.addItem();
triggers.showView();
triggers.addItem();
QCOMPARE(scripts.baseItem()->childCount(), 1);
QCOMPARE(aliases.baseItem()->childCount(), 1);
QCOMPARE(triggers.baseItem()->childCount(), 1);
scripts.showView();
scripts.treeWidget()->setCurrentItem(scripts.baseItem()->child(0));
mpEditor->slot_deleteItemOrGroup();
aliases.showView();
aliases.treeWidget()->setCurrentItem(aliases.baseItem()->child(0));
mpEditor->slot_deleteItemOrGroup();
triggers.showView();
triggers.treeWidget()->setCurrentItem(triggers.baseItem()->child(0));
mpEditor->slot_deleteItemOrGroup();
QCOMPARE(scripts.baseItem()->childCount(), 0);
QCOMPARE(aliases.baseItem()->childCount(), 0);
QCOMPARE(triggers.baseItem()->childCount(), 0);
mpEditor->mpUndoStack->undo();
QVERIFY2(triggers.baseItem()->childCount() == 1 &&
aliases.baseItem()->childCount() == 0 &&
scripts.baseItem()->childCount() == 0,
"First undo should restore only triggers");
mpEditor->mpUndoStack->undo();
QVERIFY2(triggers.baseItem()->childCount() == 1 &&
aliases.baseItem()->childCount() == 1 &&
scripts.baseItem()->childCount() == 0,
"Second undo should restore only aliases");
mpEditor->mpUndoStack->undo();
QVERIFY2(triggers.baseItem()->childCount() == 1 &&
aliases.baseItem()->childCount() == 1 &&
scripts.baseItem()->childCount() == 1,
"Third undo should restore only scripts");
for (auto &type : mItemTypes) {
type.showView();
cleanupAll(type);
}
}
// ========================================================================
// CATEGORY 8: Large Batch Operations
// ========================================================================
void testLargeBatchOperations_data() {
QTest::addColumn<int>("itemTypeIndex");
QTest::addColumn<QString>("itemTypeName");
QTest::newRow("Trigger") << 0 << "Trigger";
QTest::newRow("Timer") << 1 << "Timer";
QTest::newRow("Alias") << 2 << "Alias";
QTest::newRow("Script") << 3 << "Script";
QTest::newRow("Key") << 4 << "Key";
QTest::newRow("Action") << 5 << "Action";
}
void testLargeBatchOperations() {
QFETCH(int, itemTypeIndex);
QFETCH(QString, itemTypeName);
const auto &itemType = mItemTypes[itemTypeIndex];
itemType.showView();
cleanupAll(itemType);
// Test: Large batch operations (50 items)
int originalLimit = mpEditor->mpUndoStack->undoLimit();
mpEditor->mpUndoStack->setUndoLimit(200);
int initialCount = itemType.baseItem()->childCount();
for (int i = 0; i < 50; i++) {
itemType.addItem();
}
QCOMPARE(itemType.baseItem()->childCount(), initialCount + 50);
for (int i = 0;
i < 500 && itemType.baseItem()->childCount() > initialCount &&
mpEditor->mpUndoStack->canUndo();
i++) {
mpEditor->mpUndoStack->undo();
}
QCOMPARE(itemType.baseItem()->childCount(), initialCount);
mpEditor->mpUndoStack->clear();
mpEditor->mpUndoStack->setUndoLimit(originalLimit);
}
// ========================================================================
// CATEGORY 9: State Consistency Tests
// ========================================================================
void testStateConsistency_data() {
QTest::addColumn<int>("itemTypeIndex");
QTest::addColumn<QString>("itemTypeName");
QTest::newRow("Trigger") << 0 << "Trigger";
QTest::newRow("Timer") << 1 << "Timer";
QTest::newRow("Alias") << 2 << "Alias";
QTest::newRow("Script") << 3 << "Script";
QTest::newRow("Key") << 4 << "Key";
QTest::newRow("Action") << 5 << "Action";
}
void testStateConsistency() {
QFETCH(int, itemTypeIndex);
QFETCH(QString, itemTypeName);
const auto &itemType = mItemTypes[itemTypeIndex];
itemType.showView();
cleanupAll(itemType);
// Test: All items have valid IDs
{
itemType.addItem();
itemType.addItem();
bool allValid = true;
for (int i = 0; i < itemType.baseItem()->childCount(); i++) {
QTreeWidgetItem *item = itemType.baseItem()->child(i);
int id = item->data(0, Qt::UserRole).toInt();
if (id <= 0) {
allValid = false;
break;
}
}
QVERIFY2(allValid && itemType.baseItem()->childCount() == 2,
qPrintable(itemTypeName + ": All items should have valid IDs"));
cleanupAll(itemType);
}
// Test: Parent-child relationships intact after undo/redo
{
itemType.addFolder();
QTreeWidgetItem *folder = itemType.baseItem()->child(0);
QVERIFY(folder != nullptr);
itemType.treeWidget()->setCurrentItem(folder);
itemType.addItem();
itemType.treeWidget()->setCurrentItem(folder);
itemType.addItem();
int childCountBefore = folder->childCount();
itemType.treeWidget()->setCurrentItem(folder);
mpEditor->slot_deleteItemOrGroup();
mpEditor->mpUndoStack->undo();
QTreeWidgetItem *restored = itemType.baseItem()->child(0);
QVERIFY2(restored != nullptr &&
restored->childCount() == childCountBefore,
qPrintable(itemTypeName +
": Parent-child relationships should be intact"));
cleanupAll(itemType);
}
// Test: Deep nested hierarchy preserved after undo
// (grandparent -> parent -> 5 children, verifies all children stay
// nested under parent and don't get restored at root level)
{
int initialCount = itemType.baseItem()->childCount();
itemType.treeWidget()->setCurrentItem(itemType.baseItem());
itemType.addFolder();
if (itemType.viewType == EditorViewType::cmKeysView ||
itemType.viewType == EditorViewType::cmActionView) {
QCoreApplication::processEvents();
QThread::sleep(10ms);
}
QTreeWidgetItem *grandparent = itemType.baseItem()->child(0);
QVERIFY(grandparent != nullptr);
itemType.treeWidget()->setCurrentItem(grandparent);
itemType.addFolder();
if (itemType.viewType == EditorViewType::cmKeysView ||
itemType.viewType == EditorViewType::cmActionView) {
QCoreApplication::processEvents();
}
QTreeWidgetItem *parent = grandparent->child(0);
QVERIFY(parent != nullptr);
// Add 5 children to the parent
itemType.treeWidget()->setCurrentItem(parent);
for (int i = 0; i < 5; i++) {
itemType.addItem();
}
int childrenCount = parent->childCount();
QCOMPARE(childrenCount, 5);
// Delete the grandparent (should delete entire tree)
itemType.treeWidget()->setCurrentItem(grandparent);
mpEditor->slot_deleteItemOrGroup();
QCOMPARE(itemType.baseItem()->childCount(), initialCount);
mpEditor->mpUndoStack->undo();
QTreeWidgetItem *restoredGP = itemType.baseItem()->child(0);
QVERIFY2(restoredGP != nullptr && restoredGP->childCount() == 1,
qPrintable(itemTypeName +
": Grandparent with parent should be restored"));
QTreeWidgetItem *restoredP = restoredGP->child(0);
QVERIFY2(
restoredP != nullptr && restoredP->childCount() == childrenCount,
qPrintable(itemTypeName +
": All 5 children should be under parent, not at root"));
// Verify all children are accessible
for (int i = 0; i < childrenCount; i++) {
QVERIFY2(restoredP->child(i) != nullptr,
qPrintable(itemTypeName + ": Child " + QString::number(i) +
" should exist under parent"));
}
cleanupAll(itemType);
}
// Test: Stack command count consistency
{
int countBefore = mpEditor->mpUndoStack->count();
itemType.addItem();
int countAfter = mpEditor->mpUndoStack->count();
QVERIFY2(countAfter > countBefore,
qPrintable(itemTypeName +
": Stack count should increase with operations"));
mpEditor->mpUndoStack->clear();
QCOMPARE(mpEditor->mpUndoStack->count(), 0);
}
}
// ========================================================================
// CATEGORY 10: Error Recovery Tests
// ========================================================================
void testErrorRecovery_data() {
QTest::addColumn<int>("itemTypeIndex");
QTest::addColumn<QString>("itemTypeName");
QTest::newRow("Trigger") << 0 << "Trigger";
QTest::newRow("Timer") << 1 << "Timer";
QTest::newRow("Alias") << 2 << "Alias";
QTest::newRow("Script") << 3 << "Script";
QTest::newRow("Key") << 4 << "Key";
QTest::newRow("Action") << 5 << "Action";
}
void testErrorRecovery() {
QFETCH(int, itemTypeIndex);
QFETCH(QString, itemTypeName);
const auto &itemType = mItemTypes[itemTypeIndex];
itemType.showView();
cleanupAll(itemType);
// Test: Stack integrity after many operations
{
itemType.addItem();
itemType.addFolder();
mpEditor->mpUndoStack->undo();
itemType.addItem();
mpEditor->mpUndoStack->undo();
mpEditor->mpUndoStack->redo();
mpEditor->mpUndoStack->undo();
itemType.addItem();
for (int i = 0; i < 10 && itemType.baseItem()->childCount() > 0 &&
mpEditor->mpUndoStack->canUndo();
i++) {
mpEditor->mpUndoStack->undo();
}
QCOMPARE(itemType.baseItem()->childCount(), 0);
mpEditor->mpUndoStack->clear();
}
// Test: Cleanup verification
{
for (int i = 0; i < 5; i++) {
itemType.addItem();
}
for (int i = 0; i < 50 && itemType.baseItem()->childCount() > 0 &&
mpEditor->mpUndoStack->canUndo();
i++) {
mpEditor->mpUndoStack->undo();
}
QCOMPARE(itemType.baseItem()->childCount(), 0);
mpEditor->mpUndoStack->clear();
}
}
// ========================================================================
// CATEGORY 11: Edit Operations Tests
// ========================================================================
void testTriggerPatternEdits() {
mpEditor->slot_showTriggers();
cleanupAll(mItemTypes[0]);
mpEditor->addTrigger(false);
QVERIFY(mpEditor->mpTriggerBaseItem->childCount() > 0);
QTreeWidgetItem *trigger = mpEditor->mpTriggerBaseItem->child(0);
int triggerID = trigger->data(0, Qt::UserRole).toInt();
TTrigger *pT = mpHost->getTriggerUnit()->getTrigger(triggerID);
QVERIFY(pT != nullptr);
mpEditor->treeWidget_triggers->setCurrentItem(trigger);
mpEditor->slot_triggerSelected(trigger);
mpEditor->mpUndoStack->clear();
QString initialPattern = qsl("initial pattern");
if (mpEditor->mTriggerPatternEdit.size() > 0) {
mpEditor->mTriggerPatternEdit[0]
->singleLineTextEdit_pattern->setPlainText(initialPattern);
mpEditor->saveTrigger();
}
QString originalPattern = pT->getPatternsList().value(0);
QString newPattern = qsl("test pattern edit");
if (mpEditor->mTriggerPatternEdit.size() > 0) {
mpEditor->mTriggerPatternEdit[0]
->singleLineTextEdit_pattern->setPlainText(newPattern);
mpEditor->saveTrigger();
}
QCOMPARE(pT->getPatternsList().value(0), newPattern);
mpEditor->mpUndoStack->undo();
QCOMPARE(pT->getPatternsList().value(0), originalPattern);
mpEditor->mpUndoStack->redo();
QCOMPARE(pT->getPatternsList().value(0), newPattern);
cleanupAll(mItemTypes[0]);
}
void testTriggerNameEdits() {
mpEditor->slot_showTriggers();
cleanupAll(mItemTypes[0]);
mpEditor->addTrigger(false);
QVERIFY(mpEditor->mpTriggerBaseItem->childCount() > 0);
QTreeWidgetItem *trigger = mpEditor->mpTriggerBaseItem->child(0);
int triggerID = trigger->data(0, Qt::UserRole).toInt();
TTrigger *pT = mpHost->getTriggerUnit()->getTrigger(triggerID);
QVERIFY(pT != nullptr);
mpEditor->treeWidget_triggers->setCurrentItem(trigger);
mpEditor->slot_triggerSelected(trigger);
mpEditor->mpUndoStack->clear();
QString originalName = pT->getName();
QString newName = qsl("Edited Trigger Name");
mpEditor->mpTriggersMainArea->lineEdit_trigger_name->setText(newName);
mpEditor->saveTrigger();
QCOMPARE(pT->getName(), newName);
mpEditor->mpUndoStack->undo();
QCOMPARE(pT->getName(), originalName);
mpEditor->mpUndoStack->redo();
QCOMPARE(pT->getName(), newName);
cleanupAll(mItemTypes[0]);
}
void testTimerTimeValues() {
mpEditor->slot_showTimers();
cleanupAll(mItemTypes[1]);
mpEditor->addTimer(false);
QVERIFY(mpEditor->mpTimerBaseItem->childCount() > 0);
QTreeWidgetItem *timer = mpEditor->mpTimerBaseItem->child(0);
int timerID = timer->data(0, Qt::UserRole).toInt();
TTimer *pTimer = mpHost->getTimerUnit()->getTimer(timerID);
QVERIFY(pTimer != nullptr);
mpEditor->treeWidget_timers->setCurrentItem(timer);
mpEditor->slot_timerSelected(timer);
mpEditor->mpUndoStack->clear();
QTime originalTime = pTimer->getTime();
QTime newMinutes(0, 1, 0, 0);
QTime newSeconds(0, 0, 30, 0);
QTime newMsecs(0, 0, 0, 500);
mpEditor->mpTimersMainArea->timeEdit_timer_minutes->setTime(newMinutes);
mpEditor->mpTimersMainArea->timeEdit_timer_seconds->setTime(newSeconds);
mpEditor->mpTimersMainArea->timeEdit_timer_msecs->setTime(newMsecs);
mpEditor->saveTimer();
QTime expectedTime(0, 1, 30, 500);
QCOMPARE(pTimer->getTime(), expectedTime);
mpEditor->mpUndoStack->undo();
QCOMPARE(pTimer->getTime(), originalTime);
mpEditor->mpUndoStack->redo();
QCOMPARE(pTimer->getTime(), expectedTime);
cleanupAll(mItemTypes[1]);
}
void testTriggerPatternTypeChanges() {
mpEditor->slot_showTriggers();
cleanupAll(mItemTypes[0]);
mpEditor->addTrigger(false);
QVERIFY(mpEditor->mpTriggerBaseItem->childCount() > 0);
QTreeWidgetItem *trigger = mpEditor->mpTriggerBaseItem->child(0);
int triggerID = trigger->data(0, Qt::UserRole).toInt();
TTrigger *pT = mpHost->getTriggerUnit()->getTrigger(triggerID);
QVERIFY(pT != nullptr);
mpEditor->treeWidget_triggers->setCurrentItem(trigger);
mpEditor->slot_triggerSelected(trigger);
mpEditor->mpUndoStack->clear();
// Set initial pattern and type
QString testPattern = qsl("test pattern");
QVERIFY(mpEditor->mTriggerPatternEdit.size() > 0);
mpEditor->mTriggerPatternEdit[0]->singleLineTextEdit_pattern->setPlainText(
testPattern);
mpEditor->mTriggerPatternEdit[0]->comboBox_patternType->setCurrentIndex(
REGEX_SUBSTRING);
mpEditor->saveTrigger();
int originalType = pT->getRegexCodePropertyList().value(0);
QCOMPARE(originalType, REGEX_SUBSTRING);
// Change pattern type to Perl regex
mpEditor->mTriggerPatternEdit[0]->comboBox_patternType->setCurrentIndex(
REGEX_PERL);
mpEditor->saveTrigger();
int newType = pT->getRegexCodePropertyList().value(0);
QCOMPARE(newType, REGEX_PERL);
// Undo should restore original type
mpEditor->mpUndoStack->undo();
int typeAfterUndo = pT->getRegexCodePropertyList().value(0);
QCOMPARE(typeAfterUndo, REGEX_SUBSTRING);
// Redo should restore new type
mpEditor->mpUndoStack->redo();
int typeAfterRedo = pT->getRegexCodePropertyList().value(0);
QCOMPARE(typeAfterRedo, REGEX_PERL);
// Pattern text should remain unchanged throughout
QCOMPARE(pT->getPatternsList().value(0), testPattern);
cleanupAll(mItemTypes[0]);
}
void testAddTriggerDoesNotClearSelectedMultilineState() {
mpEditor->slot_showTriggers();
cleanupAll(mItemTypes[0]);
mpEditor->addTrigger(false);
QVERIFY(mpEditor->mpTriggerBaseItem->childCount() > 0);
QTreeWidgetItem *firstTriggerItem = mpEditor->mpTriggerBaseItem->child(0);
QVERIFY(firstTriggerItem != nullptr);
const int firstTriggerID = firstTriggerItem->data(0, Qt::UserRole).toInt();
TTrigger *firstTrigger =
mpHost->getTriggerUnit()->getTrigger(firstTriggerID);
QVERIFY(firstTrigger != nullptr);
mpEditor->treeWidget_triggers->setCurrentItem(firstTriggerItem);
mpEditor->slot_triggerSelected(firstTriggerItem);
mpEditor->mpUndoStack->clear();
mpEditor->showPatternItems(2);
QVERIFY(mpEditor->mTriggerPatternEdit.size() >= 2);
mpEditor->mTriggerPatternEdit[0]->singleLineTextEdit_pattern->setPlainText(
qsl("first line"));
mpEditor->mTriggerPatternEdit[1]->singleLineTextEdit_pattern->setPlainText(
qsl("second line"));
mpEditor->mpTriggersMainArea->spinBox_lineMargin->setValue(2);
QCoreApplication::processEvents();
mpEditor->saveTrigger();
QVERIFY(firstTrigger->isMultiline());
QCOMPARE(firstTrigger->getConditionLineDelta(), 2);
QCOMPARE(firstTrigger->getPatternsList().size(), 2);
mpEditor->addTrigger(false);
QCoreApplication::processEvents();
QCOMPARE(mpEditor->mpTriggerBaseItem->childCount(), 2);
QVERIFY2(firstTrigger->isMultiline(),
"Adding a sibling trigger should not clear the previously "
"selected trigger's multi-line state");
QCOMPARE(firstTrigger->getConditionLineDelta(), 2);
mpEditor->treeWidget_triggers->setCurrentItem(firstTriggerItem);
mpEditor->slot_triggerSelected(firstTriggerItem);
QCOMPARE(mpEditor->mpTriggersMainArea->spinBox_lineMargin->value(), 2);
cleanupAll(mItemTypes[0]);
}
void testTriggerHighlightingColor() {
mpEditor->slot_showTriggers();
cleanupAll(mItemTypes[0]);
mpEditor->addTrigger(false);
QVERIFY(mpEditor->mpTriggerBaseItem->childCount() > 0);
QTreeWidgetItem *trigger = mpEditor->mpTriggerBaseItem->child(0);
int triggerID = trigger->data(0, Qt::UserRole).toInt();
TTrigger *pT = mpHost->getTriggerUnit()->getTrigger(triggerID);
QVERIFY(pT != nullptr);
mpEditor->treeWidget_triggers->setCurrentItem(trigger);
mpEditor->slot_triggerSelected(trigger);
mpEditor->mpUndoStack->clear();
QColor originalFgColor = pT->getFgColor();
QColor newFgColor(255, 0, 0); // Red
// Set color directly (simulating color picker)
pT->setColorizerFgColor(newFgColor);
// Save to create undo command
mpEditor->saveTrigger();
QCOMPARE(pT->getFgColor(), newFgColor);
mpEditor->mpUndoStack->undo();
QCOMPARE(pT->getFgColor(), originalFgColor);
mpEditor->mpUndoStack->redo();
QCOMPARE(pT->getFgColor(), newFgColor);
cleanupAll(mItemTypes[0]);
}
void testActionButtonRotation() {
mpEditor->slot_showActions();
cleanupAll(mItemTypes[5]);
mpEditor->addAction(false);
QVERIFY(mpEditor->mpActionBaseItem->childCount() > 0);
QTreeWidgetItem *action = mpEditor->mpActionBaseItem->child(0);
mpEditor->treeWidget_actions->setCurrentItem(action);
mpEditor->slot_actionSelected(action);
mpEditor->mpUndoStack->clear();
// Get original rotation from combobox
int originalRotationIndex =
mpEditor->mpActionsMainArea->comboBox_action_button_rotation
->currentIndex();
// Set new rotation - changing combobox triggers per-property save
int newRotationIndex = 1;
mpEditor->mpActionsMainArea->comboBox_action_button_rotation
->setCurrentIndex(newRotationIndex);
QCOMPARE(mpEditor->mpActionsMainArea->comboBox_action_button_rotation
->currentIndex(),
newRotationIndex);
// Undo the rotation change
mpEditor->mpUndoStack->undo();
// Re-fetch tree widget item after undo (item may have been rebuilt)
QVERIFY(mpEditor->mpActionBaseItem->childCount() > 0);
action = mpEditor->mpActionBaseItem->child(0);
mpEditor->slot_actionSelected(action);
QCOMPARE(mpEditor->mpActionsMainArea->comboBox_action_button_rotation
->currentIndex(),
originalRotationIndex);
// Redo the rotation change
mpEditor->mpUndoStack->redo();
// Re-fetch tree widget item after redo
QVERIFY(mpEditor->mpActionBaseItem->childCount() > 0);
action = mpEditor->mpActionBaseItem->child(0);
mpEditor->slot_actionSelected(action);
QCOMPARE(mpEditor->mpActionsMainArea->comboBox_action_button_rotation
->currentIndex(),
newRotationIndex);
cleanupAll(mItemTypes[5]);
}
void testAddActionDoesNotClearSelectedPushDownButton() {
mpEditor->slot_showActions();
cleanupAll(mItemTypes[5]);
mpEditor->addAction(false);
QVERIFY(mpEditor->mpActionBaseItem->childCount() > 0);
QTreeWidgetItem *firstActionItem = mpEditor->mpActionBaseItem->child(0);
QVERIFY(firstActionItem != nullptr);
const int firstActionID = firstActionItem->data(0, Qt::UserRole).toInt();
TAction *firstAction = mpHost->getActionUnit()->getAction(firstActionID);
QVERIFY(firstAction != nullptr);
mpEditor->treeWidget_actions->setCurrentItem(firstActionItem);
mpEditor->slot_actionSelected(firstActionItem);
mpEditor->mpUndoStack->clear();
mpEditor->mpActionsMainArea->checkBox_action_button_isPushDown->setChecked(
true);
QCoreApplication::processEvents();
QVERIFY(firstAction->isPushDownButton());
QVERIFY(
mpEditor->mpActionsMainArea->checkBox_action_button_isPushDown
->isChecked());
mpEditor->addAction(false);
QCoreApplication::processEvents();
QCOMPARE(mpEditor->mpActionBaseItem->childCount(), 2);
QVERIFY2(firstAction->isPushDownButton(),
"Adding a sibling action should not clear the previously "
"selected action's push-down state");
mpEditor->treeWidget_actions->setCurrentItem(firstActionItem);
mpEditor->slot_actionSelected(firstActionItem);
QVERIFY(
mpEditor->mpActionsMainArea->checkBox_action_button_isPushDown
->isChecked());
cleanupAll(mItemTypes[5]);
}
// ========================================================================
// CATEGORY 12: Crash Prevention Tests
// ========================================================================
void testEmptyObjectDeletion() {
mpEditor->slot_showTriggers();
cleanupAll(mItemTypes[0]);
mpEditor->treeWidget_triggers->clearSelection();
mpEditor->treeWidget_triggers->setCurrentItem(nullptr);
mpEditor->slot_deleteItemOrGroup();
QVERIFY2(true, "No crash when nothing selected");
mpEditor->treeWidget_triggers->setCurrentItem(mpEditor->mpTriggerBaseItem);
mpEditor->slot_deleteItemOrGroup();
QVERIFY2(true, "No crash when base item selected");
}
void testPatternTypeSwitch() {
mpEditor->slot_showTriggers();
cleanupAll(mItemTypes[0]);
mpEditor->addTrigger(false);
QVERIFY(mpEditor->mpTriggerBaseItem->childCount() > 0);
QTreeWidgetItem *trigger = mpEditor->mpTriggerBaseItem->child(0);
mpEditor->treeWidget_triggers->setCurrentItem(trigger);
mpEditor->slot_triggerSelected(trigger);
for (int i = 0; i < 5; i++) {
mpEditor->mTriggerPatternEdit[0]->comboBox_patternType->setCurrentIndex(
0);
mpEditor->saveTrigger();
mpEditor->mTriggerPatternEdit[0]->comboBox_patternType->setCurrentIndex(
1);
mpEditor->saveTrigger();
mpEditor->mTriggerPatternEdit[0]->comboBox_patternType->setCurrentIndex(
2);
mpEditor->saveTrigger();
}
QVERIFY2(true, "No crash on multiple pattern type switches");
if (mpEditor->mpUndoStack->canUndo()) {
mpEditor->mpUndoStack->undo();
QVERIFY2(true, "No crash on undo after type switches");
}
cleanupAll(mItemTypes[0]);
}
// ========================================================================
// CATEGORY 13: Bug-Specific Tests
// ========================================================================
void testPrematureUndoActivation() {
mpEditor->slot_showTriggers();
cleanupAll(mItemTypes[0]);
int initialCount = mpEditor->mpTriggerBaseItem->childCount();
mpEditor->treeWidget_triggers->setCurrentItem(mpEditor->mpTriggerBaseItem);
bool canUndoBefore = mpEditor->mpUndoStack->canUndo();
mpEditor->mpUndoStack->undo();
int countAfterUndo = mpEditor->mpTriggerBaseItem->childCount();
QVERIFY2(!canUndoBefore || countAfterUndo == initialCount,
"No items should disappear when undo clicked without changes");
}
void testTriggerNameWiped() {
mpEditor->slot_showTriggers();
cleanupAll(mItemTypes[0]);
mpEditor->addTrigger(false);
QVERIFY(mpEditor->mpTriggerBaseItem->childCount() > 0);
QTreeWidgetItem *trigger = mpEditor->mpTriggerBaseItem->child(0);
int triggerID = trigger->data(0, Qt::UserRole).toInt();
TTrigger *pT = mpHost->getTriggerUnit()->getTrigger(triggerID);
QVERIFY(pT != nullptr);
QString triggerName = qsl("Test Trigger Name");
mpEditor->treeWidget_triggers->setCurrentItem(trigger);
mpEditor->slot_triggerSelected(trigger);
mpEditor->mpTriggersMainArea->lineEdit_trigger_name->setText(triggerName);
mpEditor->saveTrigger();
QString nameAfterSave = pT->getName();
mpEditor->addTrigger(true);
QString nameAfterGroupCreation = pT->getName();
QCOMPARE(nameAfterGroupCreation, triggerName);
QCOMPARE(nameAfterGroupCreation, nameAfterSave);
cleanupAll(mItemTypes[0]);
}
void testScriptDuplication() {
mpEditor->slot_showScripts();
cleanupAll(mItemTypes[3]);
mpEditor->addScript(false);
QCOMPARE(mpEditor->mpScriptsBaseItem->childCount(), 1);
QTreeWidgetItem *script = mpEditor->mpScriptsBaseItem->child(0);
mpEditor->treeWidget_scripts->setCurrentItem(script);
mpEditor->slot_deleteItemOrGroup();
QCOMPARE(mpEditor->mpScriptsBaseItem->childCount(), 0);
mpEditor->mpUndoStack->undo();
QCOMPARE(mpEditor->mpScriptsBaseItem->childCount(), 1);
cleanupAll(mItemTypes[3]);
}
void testMultiTriggerPasteIntoGroup() {
mpEditor->slot_showTriggers();
cleanupAll(mItemTypes[0]);
mpEditor->addTrigger(true);
QCOMPARE(mpEditor->mpTriggerBaseItem->childCount(), 1);
QTreeWidgetItem *group = mpEditor->mpTriggerBaseItem->child(0);
int groupID = group->data(0, Qt::UserRole).toInt();
TTrigger *pGroup = mpHost->getTriggerUnit()->getTrigger(groupID);
QVERIFY(pGroup != nullptr);
mpEditor->treeWidget_triggers->setCurrentItem(group);
mpEditor->addTrigger(false);
mpEditor->addTrigger(false);
QCOMPARE(group->childCount(), 2);
// copy both triggers, then paste them into the group
mpEditor->treeWidget_triggers->clearSelection();
mpEditor->treeWidget_triggers->setCurrentItem(group->child(0));
group->child(0)->setSelected(true);
group->child(1)->setSelected(true);
mpEditor->slot_copyXml();
mpEditor->treeWidget_triggers->clearSelection();
mpEditor->treeWidget_triggers->setCurrentItem(group);
mpEditor->slot_pasteXml();
// each pasted trigger must be linked into the group exactly once
QCOMPARE(static_cast<int>(pGroup->getChildrenList()->size()), 4);
// re-render the tree and verify the group shows exactly four children
QEnterEvent enterEvent{QPointF(), QPointF(), QPointF()};
QApplication::sendEvent(mpEditor, &enterEvent);
QCOMPARE(mpEditor->mpTriggerBaseItem->childCount(), 1);
QCOMPARE(mpEditor->mpTriggerBaseItem->child(0)->childCount(), 4);
cleanupAll(mItemTypes[0]);
}
void testMultiTriggerPasteAfterSibling() {
mpEditor->slot_showTriggers();
cleanupAll(mItemTypes[0]);
mpEditor->addTrigger(true);
QTreeWidgetItem *group = mpEditor->mpTriggerBaseItem->child(0);
int groupID = group->data(0, Qt::UserRole).toInt();
TTrigger *pGroup = mpHost->getTriggerUnit()->getTrigger(groupID);
QVERIFY(pGroup != nullptr);
mpEditor->treeWidget_triggers->setCurrentItem(group);
mpEditor->addTrigger(false);
mpEditor->addTrigger(false);
QCOMPARE(group->childCount(), 2);
const int firstID = group->child(0)->data(0, Qt::UserRole).toInt();
const int secondID = group->child(1)->data(0, Qt::UserRole).toInt();
// copy both triggers, then paste them onto the first (non-folder) trigger
mpEditor->treeWidget_triggers->clearSelection();
mpEditor->treeWidget_triggers->setCurrentItem(group->child(0));
group->child(0)->setSelected(true);
group->child(1)->setSelected(true);
mpEditor->slot_copyXml();
mpEditor->treeWidget_triggers->clearSelection();
mpEditor->treeWidget_triggers->setCurrentItem(group->child(0));
mpEditor->slot_pasteXml();
// pasted triggers must be inserted right after the selected sibling,
// not appended at the end of the group
auto *children = pGroup->getChildrenList();
QCOMPARE(static_cast<int>(children->size()), 4);
QList<int> childIDs;
for (auto *child : *children) {
childIDs << child->getID();
}
QCOMPARE(childIDs.at(0), firstID);
QVERIFY(childIDs.at(1) != firstID && childIDs.at(1) != secondID);
QVERIFY(childIDs.at(2) != firstID && childIDs.at(2) != secondID);
QCOMPARE(childIDs.at(3), secondID);
cleanupAll(mItemTypes[0]);
}
void testMultiAliasPasteIntoGroup() {
mpEditor->slot_showAliases();
cleanupAll(mItemTypes[2]);
mpEditor->addAlias(true);
QCOMPARE(mpEditor->mpAliasBaseItem->childCount(), 1);
QTreeWidgetItem *group = mpEditor->mpAliasBaseItem->child(0);
int groupID = group->data(0, Qt::UserRole).toInt();
TAlias *pGroup = mpHost->getAliasUnit()->getAlias(groupID);
QVERIFY(pGroup != nullptr);
mpEditor->treeWidget_aliases->setCurrentItem(group);
mpEditor->addAlias(false);
mpEditor->addAlias(false);
QCOMPARE(group->childCount(), 2);
// copy both aliases, then paste them into the group
mpEditor->treeWidget_aliases->clearSelection();
mpEditor->treeWidget_aliases->setCurrentItem(group->child(0));
group->child(0)->setSelected(true);
group->child(1)->setSelected(true);
mpEditor->slot_copyXml();
mpEditor->treeWidget_aliases->clearSelection();
mpEditor->treeWidget_aliases->setCurrentItem(group);
mpEditor->slot_pasteXml();
// both pasted aliases must land inside the group, each linked exactly once
QCOMPARE(static_cast<int>(pGroup->getChildrenList()->size()), 4);
// re-render the tree: the group shows four children and none at the root
QEnterEvent enterEvent{QPointF(), QPointF(), QPointF()};
QApplication::sendEvent(mpEditor, &enterEvent);
QCOMPARE(mpEditor->mpAliasBaseItem->childCount(), 1);
QCOMPARE(mpEditor->mpAliasBaseItem->child(0)->childCount(), 4);
cleanupAll(mItemTypes[2]);
}
void testMultiKeyPasteIntoGroup() {
mpEditor->slot_showKeys();
cleanupAll(mItemTypes[4]);
mpEditor->addKey(true);
QCOMPARE(mpEditor->mpKeyBaseItem->childCount(), 1);
QTreeWidgetItem *group = mpEditor->mpKeyBaseItem->child(0);
int groupID = group->data(0, Qt::UserRole).toInt();
TKey *pGroup = mpHost->getKeyUnit()->getKey(groupID);
QVERIFY(pGroup != nullptr);
mpEditor->treeWidget_keys->setCurrentItem(group);
mpEditor->addKey(false);
mpEditor->addKey(false);
QCOMPARE(group->childCount(), 2);
// copy both keys, then paste them into the group
mpEditor->treeWidget_keys->clearSelection();
mpEditor->treeWidget_keys->setCurrentItem(group->child(0));
group->child(0)->setSelected(true);
group->child(1)->setSelected(true);
mpEditor->slot_copyXml();
mpEditor->treeWidget_keys->clearSelection();
mpEditor->treeWidget_keys->setCurrentItem(group);
mpEditor->slot_pasteXml();
// both pasted keys must land inside the group, each linked exactly once
QCOMPARE(static_cast<int>(pGroup->getChildrenList()->size()), 4);
// re-render the tree: the group shows four children and none at the root
QEnterEvent enterEvent{QPointF(), QPointF(), QPointF()};
QApplication::sendEvent(mpEditor, &enterEvent);
QCOMPARE(mpEditor->mpKeyBaseItem->childCount(), 1);
QCOMPARE(mpEditor->mpKeyBaseItem->child(0)->childCount(), 4);
cleanupAll(mItemTypes[4]);
}
// ========================================================================
// CATEGORY 14: UI Pattern Clearing Tests
// ========================================================================
void testTriggerPatternUIClearing() {
mpEditor->slot_showTriggers();
while (mpEditor->mpTriggerBaseItem->childCount() > 0) {
mpEditor->treeWidget_triggers->setCurrentItem(
mpEditor->mpTriggerBaseItem->child(0));
mpEditor->slot_deleteItemOrGroup();
}
mpEditor->mpUndoStack->clear();
mpEditor->addTrigger(false);
QVERIFY(mpEditor->mpTriggerBaseItem->childCount() > 0);
QTreeWidgetItem *trigger = mpEditor->mpTriggerBaseItem->child(0);
int triggerID = trigger->data(0, Qt::UserRole).toInt();
TTrigger *pT = mpHost->getTriggerUnit()->getTrigger(triggerID);
QVERIFY(pT != nullptr);
QStringList patterns;
QList<int> patternTypes;
patterns << qsl("pattern1") << qsl("pattern2") << qsl("pattern3");
patternTypes << REGEX_SUBSTRING << REGEX_PERL
<< REGEX_BEGIN_OF_LINE_SUBSTRING;
pT->setRegexCodeList(patterns, patternTypes);
mpEditor->treeWidget_triggers->setCurrentItem(trigger);
mpEditor->slot_triggerSelected(trigger);
bool patternsLoaded = true;
for (int i = 0; i < 3; i++) {
QString uiPattern = mpEditor->mTriggerPatternEdit[i]
->singleLineTextEdit_pattern->toPlainText();
if (uiPattern != patterns[i]) {
patternsLoaded = false;
break;
}
}
QVERIFY2(patternsLoaded, "Patterns should be loaded in UI");
mpEditor->treeWidget_triggers->setCurrentItem(trigger);
mpEditor->slot_deleteItemOrGroup();
QList<int> affectedIDs;
affectedIDs << triggerID;
mpEditor->slot_itemsChanged(EditorViewType::cmTriggerView, affectedIDs);
bool patternsCleared = true;
for (int i = 0; i < 3; i++) {
QString uiPattern = mpEditor->mTriggerPatternEdit[i]
->singleLineTextEdit_pattern->toPlainText();
if (!uiPattern.isEmpty()) {
patternsCleared = false;
break;
}
}
QVERIFY2(patternsCleared, "Patterns should be cleared when item not found");
bool fieldsCleared =
mpEditor->mpTriggersMainArea->lineEdit_trigger_name->text().isEmpty() &&
mpEditor->mpTriggersMainArea->label_idNumber->text().isEmpty();
QVERIFY2(fieldsCleared, "Name/ID fields should be cleared");
mpEditor->mpUndoStack->clear();
}
void testTriggerPatternUIClearingWithEmptyIds() {
mpEditor->slot_showTriggers();
while (mpEditor->mpTriggerBaseItem->childCount() > 0) {
mpEditor->treeWidget_triggers->setCurrentItem(
mpEditor->mpTriggerBaseItem->child(0));
mpEditor->slot_deleteItemOrGroup();
}
mpEditor->mpUndoStack->clear();
mpEditor->addTrigger(false);
QVERIFY(mpEditor->mpTriggerBaseItem->childCount() > 0);
QTreeWidgetItem *trigger = mpEditor->mpTriggerBaseItem->child(0);
int triggerID = trigger->data(0, Qt::UserRole).toInt();
TTrigger *pT = mpHost->getTriggerUnit()->getTrigger(triggerID);
QVERIFY(pT != nullptr);
QStringList patterns;
QList<int> patternTypes;
patterns << qsl("test1") << qsl("test2");
patternTypes << REGEX_SUBSTRING << REGEX_PERL;
pT->setRegexCodeList(patterns, patternTypes);
mpEditor->treeWidget_triggers->setCurrentItem(trigger);
mpEditor->slot_triggerSelected(trigger);
QList<int> emptyList;
mpEditor->slot_itemsChanged(EditorViewType::cmTriggerView, emptyList);
bool patternsCleared = true;
for (int i = 0; i < 2; i++) {
QString uiPattern = mpEditor->mTriggerPatternEdit[i]
->singleLineTextEdit_pattern->toPlainText();
if (!uiPattern.isEmpty()) {
patternsCleared = false;
break;
}
}
QVERIFY2(patternsCleared,
"Patterns should be cleared when affectedItemIDs is empty");
mpEditor->mpUndoStack->clear();
}
// ========================================================================
// CATEGORY 15: Alias autosave guards mirror the explicit Save button
// ========================================================================
void testAliasAutosaveFlagsInvalidRegex() {
mpEditor->slot_showAliases();
cleanupAll(mItemTypes[2]);
mpEditor->addAlias(false);
QVERIFY(mpEditor->mpAliasBaseItem->childCount() > 0);
QTreeWidgetItem *item = mpEditor->mpAliasBaseItem->child(0);
const int aliasID = item->data(0, Qt::UserRole).toInt();
TAlias *pT = mpHost->getAliasUnit()->getAlias(aliasID);
QVERIFY(pT != nullptr);
mpEditor->treeWidget_aliases->setCurrentItem(item);
mpEditor->slot_aliasSelected(item);
// Type an invalid pattern and finish editing (the autosave path).
mpEditor->mpAliasMainArea->lineEdit_alias_pattern->setText(qsl("("));
mpEditor->slot_saveProperty_AliasPattern();
// The broken pattern must be flagged, just like clicking Save would.
QVERIFY2(!pT->state(),
"invalid regex should leave the alias in an error state");
QCOMPARE(item->data(0, Qt::AccessibleDescriptionRole).toString(),
mpEditor->descError);
// Fixing the pattern must clear the error flag on autosave. This alias was
// never explicitly saved, so it recovers to the "unsaved/new" state.
mpEditor->mpAliasMainArea->lineEdit_alias_pattern->setText(qsl("^hello$"));
mpEditor->slot_saveProperty_AliasPattern();
QVERIFY2(pT->state(), "valid regex should clear the error state");
QCOMPARE(item->data(0, Qt::AccessibleDescriptionRole).toString(),
mpEditor->descNewItem);
cleanupAll(mItemTypes[2]);
}
void testAliasAutosaveGuardsInfiniteLoop() {
mpEditor->slot_showAliases();
cleanupAll(mItemTypes[2]);
mpEditor->addAlias(false);
QVERIFY(mpEditor->mpAliasBaseItem->childCount() > 0);
QTreeWidgetItem *item = mpEditor->mpAliasBaseItem->child(0);
const int aliasID = item->data(0, Qt::UserRole).toInt();
TAlias *pT = mpHost->getAliasUnit()->getAlias(aliasID);
QVERIFY(pT != nullptr);
mpEditor->treeWidget_aliases->setCurrentItem(item);
mpEditor->slot_aliasSelected(item);
mpEditor->mpAliasMainArea->lineEdit_alias_pattern->setText(qsl("^say"));
mpEditor->slot_saveProperty_AliasPattern();
QVERIFY(pT->state());
// A substitution that matches its own pattern would call the alias forever;
// the autosave path must reject it, just like the explicit Save button.
mpEditor->mpAliasMainArea->lineEdit_alias_command->setText(qsl("say hello"));
mpEditor->slot_saveProperty_AliasCommand();
QVERIFY2(pT->getCommand() != qsl("say hello"),
"a self-matching substitution must not be saved");
QCOMPARE(item->data(0, Qt::AccessibleDescriptionRole).toString(),
mpEditor->descError);
cleanupAll(mItemTypes[2]);
}
void testAliasAutosavePatternLoopGuard() {
mpEditor->slot_showAliases();
cleanupAll(mItemTypes[2]);
mpEditor->addAlias(false);
QVERIFY(mpEditor->mpAliasBaseItem->childCount() > 0);
QTreeWidgetItem *item = mpEditor->mpAliasBaseItem->child(0);
const int aliasID = item->data(0, Qt::UserRole).toInt();
TAlias *pT = mpHost->getAliasUnit()->getAlias(aliasID);
QVERIFY(pT != nullptr);
mpEditor->treeWidget_aliases->setCurrentItem(item);
mpEditor->slot_aliasSelected(item);
// Give it a command that does not loop with the (empty) pattern.
mpEditor->mpAliasMainArea->lineEdit_alias_command->setText(qsl("wave"));
mpEditor->slot_saveProperty_AliasCommand();
QCOMPARE(pT->getCommand(), qsl("wave"));
// Editing the pattern so it matches the command must be rejected too - the
// loop guard has to fire from the pattern slot, not just the command slot.
mpEditor->mpAliasMainArea->lineEdit_alias_pattern->setText(qsl("^wave"));
mpEditor->slot_saveProperty_AliasPattern();
QVERIFY2(pT->getRegexCode() != qsl("^wave"),
"a pattern matching its own substitution must not be saved");
QCOMPARE(item->data(0, Qt::AccessibleDescriptionRole).toString(),
mpEditor->descError);
cleanupAll(mItemTypes[2]);
}
void testAliasAutosaveClearsErrorAfterFix() {
mpEditor->slot_showAliases();
cleanupAll(mItemTypes[2]);
mpEditor->addAlias(false);
QVERIFY(mpEditor->mpAliasBaseItem->childCount() > 0);
QTreeWidgetItem *item = mpEditor->mpAliasBaseItem->child(0);
const int aliasID = item->data(0, Qt::UserRole).toInt();
TAlias *pT = mpHost->getAliasUnit()->getAlias(aliasID);
QVERIFY(pT != nullptr);
mpEditor->treeWidget_aliases->setCurrentItem(item);
mpEditor->slot_aliasSelected(item);
mpEditor->mpAliasMainArea->lineEdit_alias_pattern->setText(qsl("^say"));
mpEditor->slot_saveProperty_AliasPattern();
QVERIFY(pT->state());
// A looping command is rejected and flags the item.
mpEditor->mpAliasMainArea->lineEdit_alias_command->setText(qsl("say hi"));
mpEditor->slot_saveProperty_AliasCommand();
QCOMPARE(item->data(0, Qt::AccessibleDescriptionRole).toString(),
mpEditor->descError);
// Correcting the command must clear the flag and persist the new value.
mpEditor->mpAliasMainArea->lineEdit_alias_command->setText(qsl("wave"));
mpEditor->slot_saveProperty_AliasCommand();
QCOMPARE(pT->getCommand(), qsl("wave"));
QVERIFY2(item->data(0, Qt::AccessibleDescriptionRole).toString() !=
mpEditor->descError,
"correcting the command must clear the loop error flag");
cleanupAll(mItemTypes[2]);
}
};
#include "dlgTriggerEditorUndoRedoTest.moc"
QTEST_MAIN(dlgTriggerEditorUndoRedoTest)