mirror of
https://github.com/Mudlet/Mudlet
synced 2026-08-13 18:26:27 -04:00
#### Brief overview of PR changes/additions - line wrapping now always moves on: a width too narrow for a single glyph used to break the line at the character the scan was already on, looping forever on the main thread - `setWindowWrap()` refuses widths below 1 (the range Preferences offers) and answers `true` when it accepts one; Geyser's `setWrap()` passes a refusal on and its autoWrap never derives 0 columns - new `NarrowWindowWrapTest` (9 cases, 5 of them verified to hang without the fix) plus busted coverage of the Lua and Geyser contracts #### Motivation for adding to Mudlet `setWindowWrap(0)` froze Mudlet completely as soon as the next line was displayed, and so did an ordinary wrap width of 1 with East Asian text, or an indent that used the width up. #### Other info (issues closed, discussion etc) Fixes #9622 **Test case:** `lua setWindowWrap(0)` then `lua getWindowWrap()` - Mudlet used to freeze; now the first call reports "wrapAt must be greater than zero" and the client keeps running. Assisted-by: Claude:claude-opus-5
419 lines
17 KiB
C++
419 lines
17 KiB
C++
/***************************************************************************
|
|
* Copyright (C) 2026 by Mudlet Makers *
|
|
* *
|
|
* 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 <atomic>
|
|
#include <functional>
|
|
#include <thread>
|
|
|
|
#include "Host.h"
|
|
#include "MudletInstanceCoordinator.h"
|
|
#include "TLuaInterpreter.h"
|
|
#include "TMainConsole.h"
|
|
#include "TelnetServerStub.h"
|
|
#include "ctelnet.h"
|
|
#include "dlgConnectionProfiles.h"
|
|
#include "mudlet.h"
|
|
|
|
extern void qInitResources_mudlet();
|
|
extern void qInitResources_qm();
|
|
extern void qInitResources_additional_splash_screens();
|
|
extern void qInitResources_mudlet_fonts_common();
|
|
extern void qInitResources_mudlet_fonts_posix();
|
|
void initializeQRCResources();
|
|
|
|
// A wrap width that cannot hold a single glyph - because it is zero, because
|
|
// the glyph is wider than the width, or because the indentation uses the width
|
|
// up - made TBuffer::getWrapInfo() break the line at the character it was
|
|
// already sitting on, so the scan never advanced and Mudlet hung (#9622).
|
|
// Every step that can reach the wrapping therefore runs under a watchdog: a
|
|
// regression is an endless loop on the main thread, so no assertion after it
|
|
// would ever be reached.
|
|
class NarrowWindowWrapTest : public QObject
|
|
{
|
|
Q_OBJECT
|
|
|
|
private:
|
|
TelnetServerStub* mpServer = nullptr;
|
|
const QString mHostname = "Test-NarrowWrap";
|
|
QString mPort; // assigned the stub's actual loopback port in init()
|
|
const QString mLocalhost = "localhost";
|
|
const QString mMiniConsole = "wrapTest";
|
|
// U+6F22 U+5B57 - East Asian Wide, so two columns are needed per glyph
|
|
const QString mWideText = QString(QChar(0x6F22)) + QChar(0x5B57);
|
|
|
|
private slots:
|
|
void initTestCase() { initializeQRCResources(); }
|
|
|
|
void init()
|
|
{
|
|
mpServer = new TelnetServerStub(qApp);
|
|
// Port 0 asks the OS for an ephemeral port so parallel test runs do
|
|
// not collide on a hardcoded one
|
|
mpServer->start(mLocalhost, 0);
|
|
QVERIFY2(mpServer->isListening(), "TelnetServerStub failed to bind a loopback port");
|
|
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);
|
|
}
|
|
|
|
// The report from #9622, at the level the Lua API no longer allows:
|
|
// TConsole::setWrapAt() is still reachable from C++, so the wrapping itself
|
|
// has to cope with a width of zero instead of spinning forever.
|
|
void test_zeroWrapWidthDoesNotHang()
|
|
{
|
|
startProfile();
|
|
auto* console = createTestMiniConsole();
|
|
QVERIFY(console);
|
|
console->setWrapAt(0);
|
|
|
|
runWithWatchdog("echo at a wrap width of zero", [this]() {
|
|
runLua(qsl("echo('%1', 'abcdef\\n')").arg(mMiniConsole));
|
|
});
|
|
|
|
// no width can hold a character, so every character ends up on a line
|
|
// of its own - and not one of them may be dropped or duplicated
|
|
QCOMPARE(nonEmptyLineCount(console), 6);
|
|
QCOMPARE(joinedText(console), qsl("abcdef"));
|
|
}
|
|
|
|
// Newlines inside the echoed text take their own path through the wrapping
|
|
// scan, which has to keep its bookkeeping straight alongside the forced
|
|
// per-glyph breaks.
|
|
void test_embeddedNewlinesAtZeroWrapWidthDoNotHang()
|
|
{
|
|
startProfile();
|
|
auto* console = createTestMiniConsole();
|
|
QVERIFY(console);
|
|
console->setWrapAt(0);
|
|
|
|
runWithWatchdog("echo of embedded newlines at a wrap width of zero", [this]() {
|
|
runLua(qsl("echo('%1', 'ab\\ncd\\n')").arg(mMiniConsole));
|
|
});
|
|
|
|
QCOMPARE(joinedText(console), qsl("abcd"));
|
|
}
|
|
|
|
// A width of one column with two-column glyphs is the same dead end, and
|
|
// unlike a width of zero it is a perfectly ordinary thing to ask for.
|
|
void test_wrapWidthNarrowerThanTheGlyphDoesNotHang()
|
|
{
|
|
startProfile();
|
|
auto* console = createTestMiniConsole();
|
|
QVERIFY(console);
|
|
runLua(qsl("setWindowWrap('%1', 1)").arg(mMiniConsole));
|
|
|
|
runWithWatchdog("echo of a wide glyph at a wrap width of one", [this]() {
|
|
runLua(qsl("echo('%1', '%2\\n')").arg(mMiniConsole, mWideText));
|
|
});
|
|
|
|
QCOMPARE(nonEmptyLineCount(console), 2);
|
|
QCOMPARE(joinedText(console), mWideText);
|
|
}
|
|
|
|
// Indentation is subtracted from the wrap width, so a legal width and a
|
|
// legal indent together can still leave less room than one glyph needs.
|
|
void test_indentEatingTheWrapWidthDoesNotHang()
|
|
{
|
|
startProfile();
|
|
auto* console = createTestMiniConsole();
|
|
QVERIFY(console);
|
|
runLua(qsl("setWindowWrap('%1', 5)").arg(mMiniConsole));
|
|
// both, so that whichever of the two a line uses leaves a single column
|
|
runLua(qsl("setWindowWrapIndent('%1', 4)").arg(mMiniConsole));
|
|
runLua(qsl("setWindowWrapHangingIndent('%1', 4)").arg(mMiniConsole));
|
|
|
|
runWithWatchdog("echo of a wide glyph with the indent using up the wrap width", [this]() {
|
|
runLua(qsl("echo('%1', '%2\\n')").arg(mMiniConsole, mWideText));
|
|
});
|
|
|
|
QCOMPARE(textIgnoringIndentation(console), mWideText);
|
|
}
|
|
|
|
// An indent at or beyond the wrap width is not range-checked anywhere.
|
|
// wrapLine() drops such an indent instead of leaving no room at all, and
|
|
// that is what keeps this case out of the trap the one above falls into.
|
|
void test_indentWiderThanTheWrapWidthDoesNotHang()
|
|
{
|
|
startProfile();
|
|
auto* console = createTestMiniConsole();
|
|
QVERIFY(console);
|
|
runLua(qsl("setWindowWrap('%1', 5)").arg(mMiniConsole));
|
|
runLua(qsl("setWindowWrapIndent('%1', 10)").arg(mMiniConsole));
|
|
runLua(qsl("setWindowWrapHangingIndent('%1', 10)").arg(mMiniConsole));
|
|
|
|
runWithWatchdog("echo with an indent wider than the wrap width", [this]() {
|
|
runLua(qsl("echo('%1', '%2%2\\n')").arg(mMiniConsole, mWideText));
|
|
});
|
|
|
|
QCOMPARE(textIgnoringIndentation(console), mWideText + mWideText);
|
|
}
|
|
|
|
// insertText() wraps against the screen width and the profile's own indent
|
|
// rather than the console's, so it reaches the wrapping by a different
|
|
// route than echo() does.
|
|
void test_insertTextIntoTheMainConsoleDoesNotHang()
|
|
{
|
|
mpServer->setWelcomeMessage(qsl("HELLO\r\n"));
|
|
startProfile();
|
|
auto* host = mudlet::self()->getActiveHost();
|
|
QVERIFY2(waitForMainConsoleText(qsl("HELLO")), "Welcome text never reached the buffer");
|
|
|
|
// leave a single column free of the screen width the insert wraps at -
|
|
// both indents, since only the first segment of a line uses the plain
|
|
// one and every segment after it uses the hanging one
|
|
const int indent = host->mScreenWidth - 1;
|
|
QVERIFY2(indent > 1, "the main console reported no usable screen width");
|
|
runLua(qsl("setWindowWrapIndent('main', %1)").arg(indent));
|
|
runLua(qsl("setWindowWrapHangingIndent('main', %1)").arg(indent));
|
|
|
|
// mid-line, so the insert goes through insertInLine() rather than the
|
|
// append path the cursor at the end of the buffer would take
|
|
const int welcomeLine = mainConsoleLineOf(qsl("HELLO"));
|
|
QVERIFY2(welcomeLine >= 0, "the welcome line went missing from the buffer");
|
|
QVERIFY2(host->mpConsole->moveCursor(2, welcomeLine), "could not position the user cursor mid-line");
|
|
|
|
runWithWatchdog("insertText of a wide glyph with the indent using up the screen width", [this, host]() {
|
|
// the newline is what makes the insert re-wrap the line it landed in
|
|
host->mpConsole->insertText(mWideText + QChar::LineFeed + mWideText);
|
|
});
|
|
|
|
QVERIFY2(mainConsoleContains(mWideText), "the inserted text did not survive wrapping");
|
|
}
|
|
|
|
// Nothing can be shown in a window that is zero columns wide, so the Lua
|
|
// API turns such a width away rather than let it reach the wrapping.
|
|
void test_setWindowWrapRejectsWidthsBelowOne()
|
|
{
|
|
startProfile();
|
|
auto* console = createTestMiniConsole();
|
|
QVERIFY(console);
|
|
// wide enough that the reported result is not itself wrapped
|
|
runLua(qsl("setWindowWrap('%1', 200)").arg(mMiniConsole));
|
|
|
|
// under the watchdog as well: were the width to be accepted, the echo
|
|
// reporting the result would be the thing that hangs
|
|
runWithWatchdog("setWindowWrap() with a width of zero", [this]() {
|
|
runLua(qsl("local ok, err = setWindowWrap('%1', 0) echo('%1', 'RESULT:'..tostring(ok)..':'..tostring(err))").arg(mMiniConsole));
|
|
});
|
|
|
|
const QString result = joinedText(console);
|
|
QVERIFY2(result.startsWith(qsl("RESULT:nil:")), qPrintable(qsl("setWindowWrap() did not refuse a wrap width of zero, it returned: %1").arg(result)));
|
|
QVERIFY2(result.contains(qsl("greater than zero")), qPrintable(qsl("the refusal did not say why: %1").arg(result)));
|
|
// the rejected call must not have changed the width either
|
|
QCOMPARE(console->getWrapAt(), 200);
|
|
}
|
|
|
|
// An accepted width answers true, so that the usual `if not ok then` check
|
|
// does not read every successful call as a failure.
|
|
void test_setWindowWrapReportsSuccess()
|
|
{
|
|
startProfile();
|
|
auto* console = createTestMiniConsole();
|
|
QVERIFY(console);
|
|
|
|
runLua(qsl("local ok = setWindowWrap('%1', 200) echo('%1', 'RESULT:'..tostring(ok))").arg(mMiniConsole));
|
|
|
|
QCOMPARE(joinedText(console), qsl("RESULT:true"));
|
|
QCOMPARE(console->getWrapAt(), 200);
|
|
}
|
|
|
|
// The main console's width is mirrored into the profile and reported to the
|
|
// game, so a refused width must not reach either.
|
|
void test_rejectedMainConsoleWidthLeavesTheProfileUntouched()
|
|
{
|
|
startProfile();
|
|
auto* host = mudlet::self()->getActiveHost();
|
|
runLua(qsl("setWindowWrap(80)"));
|
|
QCOMPARE(host->mWrapAt, 80);
|
|
|
|
runWithWatchdog("setWindowWrap() with a width of zero on the main console", [this]() {
|
|
runLua(qsl("setWindowWrap(0)"));
|
|
});
|
|
|
|
QCOMPARE(host->mWrapAt, 80);
|
|
QCOMPARE(host->mpConsole->getWrapAt(), 80);
|
|
}
|
|
|
|
void cleanup()
|
|
{
|
|
const QString profilePath = mudlet::getMudletPath(enums::profileHomePath, mHostname);
|
|
|
|
// Tear down Mudlet (and with it the live cTelnet connection) before the
|
|
// stub server it is talking to, so the socket is closed from the client
|
|
// side rather than being yanked out from under an active connection.
|
|
delete mudlet::self();
|
|
delete mpServer;
|
|
mpServer = nullptr;
|
|
deleteDirectory(profilePath);
|
|
}
|
|
|
|
private:
|
|
// Runs work on the main thread with a hard deadline: should the wrapping
|
|
// regress into an endless loop, kill the test process with a useful message
|
|
// rather than leave the whole ctest run to sit until its own timeout.
|
|
void runWithWatchdog(const char* what, const std::function<void()>& work, int timeoutSeconds = 10)
|
|
{
|
|
std::atomic_bool finished{false};
|
|
std::thread watchdog([&finished, what, timeoutSeconds]() {
|
|
for (int i = 0; i < timeoutSeconds * 10 && !finished.load(); ++i) {
|
|
QThread::msleep(100);
|
|
}
|
|
if (!finished.load()) {
|
|
qFatal("%s did not finish within %d seconds - the wrapping is stuck in a loop", what, timeoutSeconds);
|
|
}
|
|
});
|
|
work();
|
|
finished.store(true);
|
|
watchdog.join();
|
|
}
|
|
|
|
void runLua(const QString& script)
|
|
{
|
|
auto host = mudlet::self()->getActiveHost();
|
|
host->getLuaInterpreter()->compileAndExecuteScript(script);
|
|
}
|
|
|
|
// a miniconsole keeps the assertions free of the main console's connection
|
|
// messages, and wraps its text through exactly the same code
|
|
TConsole* createTestMiniConsole()
|
|
{
|
|
runLua(qsl("createMiniConsole('%1', 0, 0, 300, 300)").arg(mMiniConsole));
|
|
return mudlet::self()->getActiveHost()->mpConsole->mSubConsoleMap.value(mMiniConsole);
|
|
}
|
|
|
|
void startProfile()
|
|
{
|
|
QTimer::singleShot(0, qApp, [this]() {
|
|
mudlet::self()->startAutoLogin({});
|
|
QTest::qWait(100);
|
|
QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton);
|
|
QTest::qWait(100);
|
|
QTest::keyClicks(QApplication::focusWidget(), mHostname);
|
|
QTest::qWait(100);
|
|
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab);
|
|
QTest::qWait(100);
|
|
QTest::keyClicks(QApplication::focusWidget(), mLocalhost);
|
|
QTest::qWait(100);
|
|
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab);
|
|
QTest::qWait(100);
|
|
QTest::keyClicks(QApplication::focusWidget(), mPort);
|
|
QTest::qWait(100);
|
|
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Return);
|
|
});
|
|
|
|
QSignalSpy spy(mudlet::self(), &mudlet::signal_profileLoaded);
|
|
if (!spy.wait(5000)) {
|
|
QFAIL("Profile took too long to load.");
|
|
}
|
|
auto host = mudlet::self()->getActiveHost();
|
|
if (!host) {
|
|
QFAIL("No active host available for the test.");
|
|
}
|
|
|
|
QSignalSpy connectedSpy(&(host->mTelnet), &cTelnet::signal_connected);
|
|
if (!connectedSpy.wait(2000)) {
|
|
QFAIL("Could not connect with the host.");
|
|
}
|
|
}
|
|
|
|
// the buffer carries empty lines of its own (one is always kept ready for
|
|
// the next text), so only the lines with something in them are counted
|
|
static int nonEmptyLineCount(TConsole* console)
|
|
{
|
|
int count = 0;
|
|
for (int i = 0, total = console->buffer.getLastLineNumber(); i <= total; ++i) {
|
|
if (!console->buffer.line(i).isEmpty()) {
|
|
++count;
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
// every line of the console joined back together - the wrapping only breaks
|
|
// lines, so this has to come back out exactly as it went in
|
|
static QString joinedText(TConsole* console)
|
|
{
|
|
QString text;
|
|
for (int i = 0, total = console->buffer.getLastLineNumber(); i <= total; ++i) {
|
|
text.append(console->buffer.line(i));
|
|
}
|
|
return text;
|
|
}
|
|
|
|
// as joinedText(), but with every space dropped, for the cases where the
|
|
// wrapping pads lines out with indentation. Spaces in the text itself are
|
|
// lost along with it, so these cases echo text that has none.
|
|
static QString textIgnoringIndentation(TConsole* console) { return joinedText(console).remove(QChar::Space); }
|
|
|
|
static int mainConsoleLineOf(const QString& text)
|
|
{
|
|
auto console = mudlet::self()->getActiveHost()->mpConsole;
|
|
for (int i = 0, total = console->buffer.getLastLineNumber(); i <= total; ++i) {
|
|
if (console->buffer.line(i).contains(text)) {
|
|
return i;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
static bool mainConsoleContains(const QString& text) { return mainConsoleLineOf(text) >= 0; }
|
|
|
|
bool waitForMainConsoleText(const QString& text, int timeoutMs = 5000)
|
|
{
|
|
return QTest::qWaitFor(
|
|
[this, &text]() {
|
|
return mainConsoleContains(text);
|
|
},
|
|
timeoutMs);
|
|
}
|
|
|
|
void deleteProfileDirectory(const QString& profileName) { deleteDirectory(mudlet::getMudletPath(enums::profileHomePath, profileName)); }
|
|
|
|
void deleteDirectory(const QString& path)
|
|
{
|
|
QDir dir(path);
|
|
if (dir.exists()) {
|
|
dir.removeRecursively();
|
|
}
|
|
}
|
|
};
|
|
|
|
void initializeQRCResources()
|
|
{
|
|
#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN
|
|
qInitResources_additional_splash_screens();
|
|
#endif
|
|
#ifdef INCLUDE_FONTS
|
|
qInitResources_mudlet_fonts_common();
|
|
#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD)
|
|
qInitResources_mudlet_fonts_posix();
|
|
#endif
|
|
#endif
|
|
qInitResources_mudlet();
|
|
qInitResources_qm();
|
|
}
|
|
|
|
#include "NarrowWindowWrapTest.moc"
|
|
QTEST_MAIN(NarrowWindowWrapTest)
|