mudlet/test/functional_tests/SgrUnderlineStyleTest.cpp
Vadim Peretokin a229206d4c
fix: colon-form underline styles now match other terminals (#9498)
#### Brief overview of PR changes/additions

Corrects the SGR colon-form underline sub-parameter decode (`ESC[4:Nm`)
in `TBuffer::decodeSGR()` to match the widely-adopted kitty/VTE
convention that other terminals use:

| Sequence | Meaning (kitty/VTE) | Before | After |
| --- | --- | --- | --- |
| `4:0` | no underline | none | none |
| `4:1` | single/straight | single | single |
| `4:2` | double | dashed | single* |
| `4:3` | curly/wavy | dotted | curly/wavy |
| `4:4` | dotted | wavy | dotted |
| `4:5` | dashed | **cleared the underline** | dashed |

\* Mudlet has no distinct double-underline style, so `4:2` is shown as a
plain single underline.

Adds `SgrUnderlineStyleTest` (functional test) covering `4:0`-`4:5` from
a clean pen, style-to-style transitions (verifying sibling flags are
cleared), the out-of-range/`default` fallback, and that plain `ESC[4m`
still yields a single underline.

#### Motivation for adding to Mudlet

The colon sub-parameter mapping introduced in #8262 was shifted relative
to the kitty/VTE convention, so text a game sent with these styles
rendered as the wrong underline style. Most visibly, `4:5` (dashed)
wrongly cleared the underline entirely instead of drawing a dashed line.
This makes Mudlet match what other terminals do for these codes.

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

Relates to #8262 (which introduced the colon-form underline support).

Note: the plain numeric `ESC[4m` handler is intentionally left unchanged
here; there is a pre-existing, separate quirk that a plain `ESC[4m`
following a colon style does not clear the sibling style flags. That is
out of scope for this fix and could be addressed independently.

Assisted-by: Claude:claude-opus-4-8
2026-07-29 09:50:56 +02:00

271 lines
11 KiB
C++

/***************************************************************************
* Copyright (C) 2026 by Mudlet Developers *
* *
* 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. *
***************************************************************************/
/*
* Tests for the SGR colon-form underline sub-parameter decoding (ESC[4:Nm).
*
* The sub-parameter values follow the widely-adopted kitty/VTE convention:
* 4:0 none, 4:1 single, 4:2 double, 4:3 curly, 4:4 dotted, 4:5 dashed.
* These are decoded in TBuffer::decodeSGR(). This test injects each sequence
* and asserts the resulting cell carries the expected internal underline
* attributes - in particular that 4:5 yields a dashed underline rather than
* clearing the underline entirely.
*
* Uses loopbackTest() to inject data directly into the telnet processing
* pipeline, avoiding per-test TCP connections and profile creation.
*
* Run with: ctest -R SgrUnderlineStyleTest -V
*/
#include <QtTest/QtTest>
#include "MudletInstanceCoordinator.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 initializeQRCResourcesForUnderlineTest();
class SgrUnderlineStyleTest : public QObject
{
Q_OBJECT
private:
TelnetServerStub* mpServer = nullptr;
Host* mpHost = nullptr;
const QString mHostname = "SGR-Underline-Test-Host";
QString mPort; // assigned the stub's actual ephemeral port in initTestCase()
const QString mLocalhost = "localhost";
// Injects raw telnet data into the processing pipeline via loopback and
// waits for the buffer to process it.
void injectData(const QString& message)
{
QByteArray data = (message + qsl("\r\n")).toUtf8();
mpHost->mTelnet.loopbackTest(data);
QTest::qWait(50);
}
// Scans the buffer for the first cell whose grapheme matches marker and
// returns its TChar, or std::nullopt if none is found.
std::optional<TChar> findCell(QChar marker)
{
TMainConsole* console = mpHost->mpConsole;
for (int line = 0; line <= console->buffer.getLastLineNumber(); ++line) {
const QString& text = console->buffer.lineBuffer.at(line);
for (int col = 0; col < text.length(); ++col) {
if (text.at(col) == marker) {
return console->buffer.buffer.at(line).at(col);
}
}
}
return std::nullopt;
}
private slots:
// Start mudlet and create a profile once for all tests.
void initTestCase()
{
initializeQRCResourcesForUnderlineTest();
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);
const QString path = mudlet::getMudletPath(enums::profileHomePath, mHostname);
QDir(path).removeRecursively();
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(1000)) {
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(500)) {
QFAIL("Could not connect with the host.");
}
}
// Clear buffer before each test for isolation.
void init()
{
QVERIFY(mpHost);
QVERIFY(mpHost->mpConsole);
mpHost->mpConsole->buffer.clear();
}
// Data-driven test: each ESC[4:Nm sequence must map to the expected internal
// underline attributes following the kitty/VTE convention.
void test_ColonUnderlineStyle_data()
{
QTest::addColumn<QString>("sequence");
QTest::addColumn<bool>("underlined");
QTest::addColumn<bool>("wavy");
QTest::addColumn<bool>("dotted");
QTest::addColumn<bool>("dashed");
// under wavy dotted dashed
QTest::newRow("4:0 none") << qsl("\x1b[4:0m") << false << false << false << false;
QTest::newRow("4:1 single") << qsl("\x1b[4:1m") << true << false << false << false;
// Mudlet has no distinct double-underline style, so 4:2 shows as single.
QTest::newRow("4:2 double") << qsl("\x1b[4:2m") << true << false << false << false;
QTest::newRow("4:3 curly") << qsl("\x1b[4:3m") << true << true << false << false;
QTest::newRow("4:4 dotted") << qsl("\x1b[4:4m") << true << false << true << false;
QTest::newRow("4:5 dashed") << qsl("\x1b[4:5m") << true << false << false << true;
}
void test_ColonUnderlineStyle()
{
QFETCH(QString, sequence);
QFETCH(bool, underlined);
QFETCH(bool, wavy);
QFETCH(bool, dotted);
QFETCH(bool, dashed);
// Reset the pen with ESC[0m so no prior test's underline state leaks in,
// then apply the sequence and inspect the marker 'U' cell.
injectData(qsl("\x1b[0m") + sequence + qsl("U"));
auto cell = findCell(QLatin1Char('U'));
QVERIFY2(cell.has_value(), "Marker character 'U' not found in buffer");
QCOMPARE(cell->isUnderlined(), underlined);
QCOMPARE(cell->isUnderlineWavy(), wavy);
QCOMPARE(cell->isUnderlineDotted(), dotted);
QCOMPARE(cell->isUnderlineDashed(), dashed);
}
// Data-driven test: applying a colon style over an existing curly underline
// must clear the sibling style flags, actually turn the underline off for
// 4:0, and fall back to no underline for out-of-range values. This guards
// against the stale-state carry-over class of bug the fix addresses.
void test_ColonUnderlineStyleTransition_data()
{
QTest::addColumn<QString>("sequence");
QTest::addColumn<bool>("underlined");
QTest::addColumn<bool>("wavy");
QTest::addColumn<bool>("dotted");
QTest::addColumn<bool>("dashed");
// under wavy dotted dashed
QTest::newRow("curly then 4:0 clears") << qsl("\x1b[4:0m") << false << false << false << false;
QTest::newRow("curly then 4:4 dotted") << qsl("\x1b[4:4m") << true << false << true << false;
QTest::newRow("curly then 4:5 dashed") << qsl("\x1b[4:5m") << true << false << false << true;
// Out-of-range values hit the default arm and clear the underline.
QTest::newRow("curly then 4:6 out-of-range") << qsl("\x1b[4:6m") << false << false << false << false;
}
void test_ColonUnderlineStyleTransition()
{
QFETCH(QString, sequence);
QFETCH(bool, underlined);
QFETCH(bool, wavy);
QFETCH(bool, dotted);
QFETCH(bool, dashed);
// Establish a curly underline first, then apply the sequence under test
// to the same pen so sibling-flag clearing is exercised.
injectData(qsl("\x1b[0m\x1b[4:3m") + sequence + qsl("U"));
auto cell = findCell(QLatin1Char('U'));
QVERIFY2(cell.has_value(), "Marker character 'U' not found in buffer");
QCOMPARE(cell->isUnderlined(), underlined);
QCOMPARE(cell->isUnderlineWavy(), wavy);
QCOMPARE(cell->isUnderlineDotted(), dotted);
QCOMPARE(cell->isUnderlineDashed(), dashed);
}
// The plain numeric ESC[4m (no colon) must remain a single underline - the
// fix only touches the colon sub-parameter path.
void test_PlainUnderlineUnchanged()
{
injectData(qsl("\x1b[0m\x1b[4mU"));
auto cell = findCell(QLatin1Char('U'));
QVERIFY2(cell.has_value(), "Marker character 'U' not found in buffer");
QVERIFY(cell->isUnderlined());
QVERIFY(!cell->isUnderlineWavy());
QVERIFY(!cell->isUnderlineDotted());
QVERIFY(!cell->isUnderlineDashed());
}
void cleanupTestCase()
{
delete mpServer;
mpServer = nullptr;
mpHost = nullptr;
const QString path = mudlet::getMudletPath(enums::profileHomePath, mHostname);
QDir(path).removeRecursively();
delete mudlet::self();
}
};
void initializeQRCResourcesForUnderlineTest()
{
#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 "SgrUnderlineStyleTest.moc"
QTEST_MAIN(SgrUnderlineStyleTest)