mirror of
https://github.com/Mudlet/Mudlet
synced 2026-08-13 18:26:27 -04:00
#### Brief overview of PR changes/additions - Comment-only. `git diff origin/development...HEAD` changes no statement, expression or declaration - every added and removed line is a comment. 238 comment lines become 98. - Applies the house standard to the comments added by "fix: a trigger that re-creates itself freezes Mudlet" (#9697) and "Fix user key bindings on Ctrl+1 to Ctrl+9 and Ctrl+Tab" (#9703): no historical passages, and the rest cut to what a reader cannot derive from the code. - Corrects four claims that were wrong, two of them inherited from those PRs: a fires-per-line measurement taken with a smaller budget than the one that shipped, an over-general note on `shortcutInstalledFor()`, a `KeyUnit::disableKey()` note that had the mechanism backwards, and a test comment crediting the `isEmpty()` guard for a result it does not produce. #### Motivation for adding to Mudlet Both PRs merged while their comment-reduction pass was still in flight, so the trim never landed with them. #### Other info (issues closed, discussion etc) The gotchas worth keeping survive in shorter form: why the same-line creation budget is counted per pass rather than sharing the `feedTriggers()` depth counter, why permanent triggers get `deactivate()` and not `setIsActive(false)`, why `mCleanupSet` rather than the deactivation is what stops `enableTrigger()` resurrecting a spent trigger, that `QShortcutMap` retries with consumed modifiers stripped, and the `Key_Backtab` versus `Shift+Tab` spelling. The matching trim for "fix: stop treating long-time Mudlet users as brand new players" (#9695) already landed separately as #9707, so it is not repeated here. No demo video: a comment-only change is not observable on screen. **Test case:** `ctest` in the build directory - 79/80, with `TelnetBenchmark` timing out only under parallel load (31s standalone against a 60s limit) on a path this PR does not touch. `TriggerSameLineMatchTest`, `UnitDeferredDeleteTest`, `ProfileSwitchShortcutTest` and `ExperiencedPlayerGateTest` all pass. Assisted-by: Claude:claude-opus-5
409 lines
17 KiB
C++
409 lines
17 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. *
|
|
***************************************************************************/
|
|
|
|
/*
|
|
* The command line must claim the ShortcutOverride exactly when a live user key
|
|
* binding matches a press that would otherwise activate a profile switching
|
|
* shortcut (Ctrl+1 to Ctrl+9, Ctrl+Tab) - including presses QShortcutMap
|
|
* matches to a differently spelt shortcut - and never otherwise. Claiming it is
|
|
* the only way the binding survives, since QShortcutMap otherwise runs the
|
|
* shortcut and never delivers the KeyPress.
|
|
*
|
|
* Run with: ctest -R ProfileSwitchShortcutTest -V
|
|
*/
|
|
|
|
#include <QtTest/QtTest>
|
|
#include <chrono>
|
|
|
|
#include "Host.h"
|
|
#include "KeyUnit.h"
|
|
#include "MudletInstanceCoordinator.h"
|
|
#include "TCommandLine.h"
|
|
#include "TKey.h"
|
|
#include "TLuaInterpreter.h"
|
|
#include "ShortcutsManager.h"
|
|
#include "TMainConsole.h"
|
|
#include "TelnetServerStub.h"
|
|
#include "ctelnet.h"
|
|
#include "dlgConnectionProfiles.h"
|
|
#include "mudlet.h"
|
|
|
|
#include <QShortcut>
|
|
|
|
extern "C" {
|
|
#if defined(INCLUDE_VERSIONED_LUA_HEADERS)
|
|
#include <lua5.1/lauxlib.h>
|
|
#include <lua5.1/lua.h>
|
|
#include <lua5.1/lualib.h>
|
|
#else
|
|
#include <lauxlib.h>
|
|
#include <lua.h>
|
|
#include <lualib.h>
|
|
#endif
|
|
}
|
|
|
|
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();
|
|
void initializeQRCResourcesForProfileSwitchShortcutTest();
|
|
|
|
// Qt::CTRL is Cmd on macOS, where "next profile" uses Qt::META - see mudlet::mudlet()
|
|
#if defined(Q_OS_MACOS)
|
|
static constexpr Qt::KeyboardModifier nextProfileModifier = Qt::MetaModifier;
|
|
#else
|
|
static constexpr Qt::KeyboardModifier nextProfileModifier = Qt::ControlModifier;
|
|
#endif
|
|
|
|
class ProfileSwitchShortcutTest : public QObject
|
|
{
|
|
Q_OBJECT
|
|
|
|
private:
|
|
TelnetServerStub* mpServer = nullptr;
|
|
Host* mpHost = nullptr;
|
|
const QString mHostname = "ProfileSwitchShortcut-Test";
|
|
QString mPort; // assigned the stub's actual ephemeral port in initTestCase()
|
|
const QString mLocalhost = "localhost";
|
|
|
|
TCommandLine* commandLine() const
|
|
{
|
|
if (!mpHost || !mpHost->mpConsole) {
|
|
return nullptr;
|
|
}
|
|
return mpHost->mpConsole->mpCommandLine;
|
|
}
|
|
|
|
// QShortcutMap offers the key as an ignored ShortcutOverride and only runs
|
|
// the shortcut if nobody accepted it
|
|
bool overrideClaimed(int key, Qt::KeyboardModifiers modifiers) const
|
|
{
|
|
QKeyEvent event(QEvent::ShortcutOverride, key, modifiers);
|
|
event.ignore();
|
|
QApplication::sendEvent(commandLine(), &event);
|
|
return event.isAccepted();
|
|
}
|
|
|
|
void sendKeyPress(int key, Qt::KeyboardModifiers modifiers) const
|
|
{
|
|
QKeyEvent event(QEvent::KeyPress, key, modifiers);
|
|
QApplication::sendEvent(commandLine(), &event);
|
|
}
|
|
|
|
// Asserted where a claim is expected, so a mis-mapped sequence cannot
|
|
// masquerade as a code failure
|
|
bool shortcutInstalledFor(const QKeySequence& sequence) const
|
|
{
|
|
const auto shortcuts = mudlet::self()->findChildren<QShortcut*>();
|
|
for (auto* shortcut : shortcuts) {
|
|
if (shortcut->key() == sequence && shortcut->isEnabled()) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Alt+E on Linux and Windows, Ctrl+E on macOS
|
|
std::pair<int, Qt::KeyboardModifiers> scriptEditorShortcut() const
|
|
{
|
|
auto* sequence = mudlet::self()->shortcutsManager()->getSequence(qsl("Script editor"));
|
|
if (!sequence || sequence->isEmpty()) {
|
|
return {Qt::Key_unknown, Qt::NoModifier};
|
|
}
|
|
const QKeyCombination combination = (*sequence)[0];
|
|
return {combination.key(), combination.keyboardModifiers()};
|
|
}
|
|
|
|
int luaCounter(const QString& globalName) const
|
|
{
|
|
lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState();
|
|
lua_getglobal(L, globalName.toUtf8().constData());
|
|
const int value = static_cast<int>(lua_tointeger(L, -1));
|
|
lua_pop(L, 1);
|
|
return value;
|
|
}
|
|
|
|
int createCountingKey(const QString& name, int keycode, int modifier, const QString& counterName, const QString& parent = QString())
|
|
{
|
|
QString keyName = name;
|
|
QString parentName = parent;
|
|
QString script = qsl("%1 = (%1 or 0) + 1").arg(counterName);
|
|
auto [id, message] = mpHost->mLuaInterpreter.startPermKey(keyName, parentName, keycode, modifier, script);
|
|
if (id <= 0) {
|
|
qWarning() << "createCountingKey failed:" << message;
|
|
}
|
|
return id;
|
|
}
|
|
|
|
// Safe only while every key here is permanent and none is killed or
|
|
// uninstalled, leaving KeyUnit's deferred-delete set empty; otherwise this
|
|
// has to go through markCleanup()/doCleanup()
|
|
void removeAllKeys()
|
|
{
|
|
auto* keyUnit = mpHost->getKeyUnit();
|
|
const auto rootKeys = keyUnit->getKeyRootNodeList(); // by value: ~TKey mutates the real list
|
|
for (auto* key : rootKeys) {
|
|
delete key;
|
|
}
|
|
}
|
|
|
|
private slots:
|
|
void initTestCase()
|
|
{
|
|
initializeQRCResourcesForProfileSwitchShortcutTest();
|
|
|
|
mpServer = new TelnetServerStub(qApp);
|
|
mpServer->start(mLocalhost, 0); // ephemeral OS-assigned port avoids collisions across concurrent test runs
|
|
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(mHostname);
|
|
|
|
startProfile(mHostname, mLocalhost, mPort);
|
|
mpHost = mudlet::self()->getActiveHost();
|
|
QVERIFY2(mpHost, "No active host after profile creation");
|
|
QVERIFY2(commandLine(), "No command line available for the test");
|
|
|
|
// Checked ahead of the claim in TCommandLine::event(), so CtrlTab here
|
|
// would take Ctrl+Tab out of these tests' hands
|
|
mpHost->mCaretShortcut = Host::CaretShortcut::None;
|
|
}
|
|
|
|
void cleanupTestCase()
|
|
{
|
|
mpHost = nullptr;
|
|
delete mpServer;
|
|
mpServer = nullptr;
|
|
deleteProfileDirectory(mHostname);
|
|
delete mudlet::self();
|
|
}
|
|
|
|
void cleanup() { removeAllKeys(); }
|
|
|
|
void test_userBindingOnCtrlNumberClaimsTheShortcut()
|
|
{
|
|
QVERIFY2(shortcutInstalledFor(QKeySequence(Qt::CTRL | Qt::Key_1)), "No profile switching shortcut is installed for Ctrl+1, so this test proves nothing");
|
|
|
|
QVERIFY(createCountingKey(qsl("Ctrl+1 binding"), Qt::Key_1, Qt::ControlModifier, qsl("_testCtrl1")) > 0);
|
|
|
|
QVERIFY2(overrideClaimed(Qt::Key_1, Qt::ControlModifier), "A user key binding on Ctrl+1 did not claim the key, so the profile switch shortcut swallows it");
|
|
}
|
|
|
|
void test_withoutUserBindingTheShortcutKeepsTheKey()
|
|
{
|
|
QVERIFY2(!overrideClaimed(Qt::Key_1, Qt::ControlModifier), "Ctrl+1 was claimed even though no user key binding matches it - profile switching would stop working");
|
|
}
|
|
|
|
// Only nine shortcuts are installed, so Ctrl+0 has nothing to beat
|
|
void test_bindingOnAnUnshadowedKeyIsNotClaimed()
|
|
{
|
|
QVERIFY(createCountingKey(qsl("Ctrl+0 binding"), Qt::Key_0, Qt::ControlModifier, qsl("_testCtrl0")) > 0);
|
|
|
|
QVERIFY2(!overrideClaimed(Qt::Key_0, Qt::ControlModifier), "Ctrl+0 is not a profile switching shortcut, so the command line must not claim it");
|
|
}
|
|
|
|
void test_disabledUserBindingDoesNotClaimTheShortcut()
|
|
{
|
|
const QString name = qsl("Disabled Ctrl+2 binding");
|
|
QVERIFY(createCountingKey(name, Qt::Key_2, Qt::ControlModifier, qsl("_testCtrl2")) > 0);
|
|
QVERIFY(overrideClaimed(Qt::Key_2, Qt::ControlModifier));
|
|
|
|
QVERIFY(mpHost->getKeyUnit()->disableKey(name));
|
|
|
|
QVERIFY2(!overrideClaimed(Qt::Key_2, Qt::ControlModifier), "A disabled key binding still claimed Ctrl+2");
|
|
}
|
|
|
|
void test_userBindingOnCtrlNineClaimsTheShortcut()
|
|
{
|
|
QVERIFY2(shortcutInstalledFor(QKeySequence(Qt::CTRL | Qt::Key_9)), "No profile switching shortcut is installed for Ctrl+9, so this test proves nothing");
|
|
|
|
QVERIFY(createCountingKey(qsl("Ctrl+9 binding"), Qt::Key_9, Qt::ControlModifier, qsl("_testCtrl9")) > 0);
|
|
|
|
QVERIFY2(overrideClaimed(Qt::Key_9, Qt::ControlModifier), "A user key binding on Ctrl+9 did not claim the key");
|
|
}
|
|
|
|
void test_userBindingInDisabledGroupDoesNotClaimTheShortcut()
|
|
{
|
|
const int groupId = createCountingKey(qsl("Key Group"), -1, 0, qsl("_testGroup"));
|
|
QVERIFY(groupId > 0);
|
|
auto* group = mpHost->getKeyUnit()->getKey(groupId);
|
|
QVERIFY(group);
|
|
group->setIsActive(true);
|
|
|
|
QVERIFY(createCountingKey(qsl("Grouped Ctrl+3 binding"), Qt::Key_3, Qt::ControlModifier, qsl("_testCtrl3"), qsl("Key Group")) > 0);
|
|
QVERIFY2(overrideClaimed(Qt::Key_3, Qt::ControlModifier), "A binding in an enabled group should claim Ctrl+3");
|
|
|
|
group->setIsActive(false);
|
|
QVERIFY2(!overrideClaimed(Qt::Key_3, Qt::ControlModifier), "A binding inside a disabled group still claimed Ctrl+3");
|
|
}
|
|
|
|
void test_userBindingOnCtrlTabClaimsTheShortcut()
|
|
{
|
|
QVERIFY2(shortcutInstalledFor(QKeySequence(nextProfileModifier | Qt::Key_Tab)), "No 'Next profile' shortcut is installed for Ctrl+Tab, so this test proves nothing");
|
|
QVERIFY2(!overrideClaimed(Qt::Key_Tab, nextProfileModifier), "Ctrl+Tab was claimed with no user key binding present");
|
|
|
|
QVERIFY(createCountingKey(qsl("Ctrl+Tab binding"), Qt::Key_Tab, nextProfileModifier, qsl("_testCtrlTab")) > 0);
|
|
|
|
QVERIFY2(overrideClaimed(Qt::Key_Tab, nextProfileModifier), "A user key binding on Ctrl+Tab did not claim the key");
|
|
}
|
|
|
|
// Shift+Tab reaches the widget as Key_Backtab while the sequence is spelt
|
|
// with Key_Tab, so the match has to bridge the two spellings
|
|
void test_userBindingOnCtrlShiftTabClaimsTheShortcut()
|
|
{
|
|
const auto modifiers = nextProfileModifier | Qt::ShiftModifier;
|
|
QVERIFY2(!overrideClaimed(Qt::Key_Backtab, modifiers), "Ctrl+Shift+Tab was claimed with no user key binding present");
|
|
|
|
QVERIFY(createCountingKey(qsl("Ctrl+Shift+Tab binding"), Qt::Key_Backtab, modifiers, qsl("_testCtrlShiftTab")) > 0);
|
|
|
|
QVERIFY2(overrideClaimed(Qt::Key_Backtab, modifiers), "A user key binding on Ctrl+Shift+Tab did not claim the key");
|
|
}
|
|
|
|
void test_bindingOnAnotherApplicationShortcutIsNotClaimed()
|
|
{
|
|
auto [key, modifiers] = scriptEditorShortcut();
|
|
QVERIFY2(key != Qt::Key_unknown, "Could not read the script editor shortcut");
|
|
|
|
QVERIFY(createCountingKey(qsl("Script editor shortcut binding"), key, modifiers, qsl("_testEditor")) > 0);
|
|
|
|
QVERIFY2(!overrideClaimed(key, modifiers), "A key binding claimed the script editor shortcut, which is outside the profile switching set");
|
|
}
|
|
|
|
void test_aClearedProfileShortcutDoesNotClaimEveryKey()
|
|
{
|
|
auto* sequence = mudlet::self()->shortcutsManager()->getSequence(qsl("Switch to profile 1"));
|
|
QVERIFY2(sequence, "'Switch to profile 1' is not registered with the shortcuts manager");
|
|
const QKeySequence saved = *sequence;
|
|
*sequence = QKeySequence();
|
|
|
|
auto [key, modifiers] = scriptEditorShortcut();
|
|
QVERIFY(createCountingKey(qsl("Script editor shortcut binding"), key, modifiers, qsl("_testEditorCleared")) > 0);
|
|
QVERIFY(createCountingKey(qsl("F5 binding"), Qt::Key_F5, Qt::NoModifier, qsl("_testF5")) > 0);
|
|
|
|
const bool editorClaimed = overrideClaimed(key, modifiers);
|
|
const bool f5Claimed = overrideClaimed(Qt::Key_F5, Qt::NoModifier);
|
|
*sequence = saved;
|
|
|
|
QVERIFY2(!editorClaimed, "A cleared profile switching shortcut made an unrelated bound key claim the override");
|
|
QVERIFY2(!f5Claimed, "A cleared profile switching shortcut made an unrelated bound key claim the override");
|
|
}
|
|
|
|
// QShortcutMap retries with the keypad modifier stripped, so Ctrl and a
|
|
// numpad digit activates the plain Ctrl+1 shortcut
|
|
void test_userBindingOnAKeypadDigitClaimsTheShortcut()
|
|
{
|
|
const auto modifiers = Qt::ControlModifier | Qt::KeypadModifier;
|
|
QVERIFY(createCountingKey(qsl("Ctrl+keypad 1 binding"), Qt::Key_1, modifiers, qsl("_testKeypad1")) > 0);
|
|
|
|
QVERIFY2(overrideClaimed(Qt::Key_1, modifiers), "A user key binding on Ctrl and a keypad digit did not claim the key");
|
|
}
|
|
|
|
// Layouts needing Shift for a top-row digit (French AZERTY) record the
|
|
// binding with Shift, and QShortcutMap drops the Shift it consumed
|
|
void test_userBindingOnAShiftedDigitClaimsTheShortcut()
|
|
{
|
|
const auto modifiers = Qt::ControlModifier | Qt::ShiftModifier;
|
|
QVERIFY(createCountingKey(qsl("Ctrl+Shift+1 binding"), Qt::Key_1, modifiers, qsl("_testShift1")) > 0);
|
|
|
|
QVERIFY2(overrideClaimed(Qt::Key_1, modifiers), "A user key binding on Ctrl+Shift and a digit did not claim the key");
|
|
}
|
|
|
|
void test_claimedBindingRunsExactlyOnce()
|
|
{
|
|
QVERIFY(createCountingKey(qsl("Ctrl+4 binding"), Qt::Key_4, Qt::ControlModifier, qsl("_testCtrl4")) > 0);
|
|
QCOMPARE(luaCounter(qsl("_testCtrl4")), 0);
|
|
|
|
QVERIFY(overrideClaimed(Qt::Key_4, Qt::ControlModifier));
|
|
QCOMPARE(luaCounter(qsl("_testCtrl4")), 0); // the probe must not execute anything
|
|
|
|
sendKeyPress(Qt::Key_4, Qt::ControlModifier);
|
|
QCOMPARE(luaCounter(qsl("_testCtrl4")), 1);
|
|
}
|
|
|
|
private:
|
|
void startProfile(const QString& hostname, const QString& address, const QString& port)
|
|
{
|
|
QTimer::singleShot(0ms, qApp, [hostname, address, port]() {
|
|
mudlet::self()->startAutoLogin({});
|
|
QTest::qWait(100ms);
|
|
QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton);
|
|
QTest::qWait(100ms);
|
|
QTest::keyClicks(QApplication::focusWidget(), hostname);
|
|
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.");
|
|
}
|
|
auto host = mudlet::self()->getActiveHost();
|
|
if (!host) {
|
|
QFAIL("No active host available for the test.");
|
|
}
|
|
|
|
QSignalSpy connectionSpy(&(host->mTelnet), &cTelnet::signal_connected);
|
|
if (!connectionSpy.wait(2000)) {
|
|
QFAIL("Could not connect with the host.");
|
|
}
|
|
}
|
|
|
|
void deleteProfileDirectory(const QString& profileName)
|
|
{
|
|
const QString path = mudlet::getMudletPath(enums::profileHomePath, profileName);
|
|
QDir dir(path);
|
|
|
|
if (!dir.exists()) {
|
|
return;
|
|
}
|
|
dir.removeRecursively();
|
|
}
|
|
};
|
|
|
|
void initializeQRCResourcesForProfileSwitchShortcutTest()
|
|
{
|
|
#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 "ProfileSwitchShortcutTest.moc"
|
|
QTEST_MAIN(ProfileSwitchShortcutTest)
|