mudlet/test/functional_tests/TOscTest.cpp
Vadim Peretokin d24c6af945
fix: script output no longer breaks the game's colour and control codes (#9485)
#### Brief overview of PR changes/additions
- Include `mGotString` (DCS/SOS/PM/APC parser state) in the
server<->local channel swap so string-sequence state no longer leaks
between the game stream and local feeds
- Remove the stray-ESC clear block #9436 added, which #9433 already made
unreachable
- Add the regression test #9436 lacked; `TOscTest` now uses an ephemeral
telnet-stub port so concurrent runs cannot collide

#### Motivation for adding to Mudlet
Without the swap, a local OSC was silently not decoded and a split
game-side DCS payload was wrongly decoded as an OSC, corrupting
colour/control-code handling.

#### Other info (issues closed, discussion etc)
Follow-up to #9436 (found during a post-merge QA review).

**Test case:** Run the functional test suite; `TOscTest` covers the
DCS-leak and stray-ESC scenarios. Manually: send a DCS payload split
across two packets from the game and confirm it is not misdecoded, and
feed a local OSC and confirm it is decoded.

Assisted-by: Claude:claude-fable-5
2026-07-27 19:17:18 +02:00

1444 lines
57 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 OSC (Operating System Command) escape sequence handling.
*
* Uses loopbackTest() to inject data directly into the telnet processing
* pipeline, avoiding per-test TCP connections and profile creation.
* Mudlet is started once in initTestCase and reused across all tests.
*
* Run with: ctest -R TOscTest -V
*/
#include <QKeyEvent>
#include <QtTest/QtTest>
#include <chrono>
#include "MudletInstanceCoordinator.h"
#include "TAccessibleTextEdit.h"
#include "THyperlinkStyling.h"
#include "TLinkStore.h"
#include "TMainConsole.h"
#include "TelnetServerStub.h"
#include "ctelnet.h"
#include "dlgConnectionProfiles.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();
void initializeQRCResourcesForOscTest();
class TOscTest : public QObject {
Q_OBJECT
private:
TelnetServerStub *mpServer = nullptr;
Host *mpHost = nullptr;
const QString mHostname = "OSC-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(50ms);
}
// Scans backward through the buffer to find the first hyperlink and returns
// its command list from the link store.
QStringList findFirstLinkCommands() {
if (!mpHost || !mpHost->mpConsole) {
return {};
}
TMainConsole *console = mpHost->mpConsole;
int linkId = 0;
for (int line = console->buffer.getLastLineNumber();
line >= 0 && linkId == 0; --line) {
linkId = console->buffer.getLinkIndexAt(line, 0);
}
if (linkId <= 0) {
return {};
}
return console->getLinkStore().getLinksConst(linkId);
}
// Scans backward through the buffer to find the first hyperlink and returns
// its HyperlinkStyling from the link store.
Mudlet::HyperlinkStyling findFirstLinkStyling() {
if (!mpHost || !mpHost->mpConsole) {
return {};
}
TMainConsole *console = mpHost->mpConsole;
int linkId = 0;
for (int line = console->buffer.getLastLineNumber();
line >= 0 && linkId == 0; --line) {
linkId = console->buffer.getLinkIndexAt(line, 0);
}
if (linkId <= 0) {
return {};
}
return console->getLinkStore().getStyling(linkId);
}
// Builds an OSC 8 hyperlink message with optional JSON config in the URL.
// The config is appended as a ?config= query parameter.
QString buildOsc8WithConfig(const QString &baseUrl, const QString &linkText,
const QString &jsonConfig) {
QString url = baseUrl;
if (!jsonConfig.isEmpty()) {
url += qsl("?config=") + jsonConfig;
}
return qsl("\x1b]8;;") + url + qsl("\x1b\\") + linkText +
qsl("\x1b]8;;\x1b\\");
}
// Scans forward through the buffer to find the first hyperlink and returns
// its (line, column) position, or std::nullopt if none found.
std::optional<std::pair<int, int>> findFirstLinkPosition() {
if (!mpHost || !mpHost->mpConsole) {
return std::nullopt;
}
TMainConsole *console = mpHost->mpConsole;
for (int line = 0; line <= console->buffer.getLastLineNumber(); ++line) {
for (int col = 0; col < console->buffer.line(line).length(); ++col) {
if (console->buffer.getLinkIndexAt(line, col) > 0) {
return std::pair{line, col};
}
}
}
return std::nullopt;
}
// Scans forward through the buffer to find the first hyperlink and returns
// its (line, column, linkId), or std::nullopt if none found.
std::optional<std::tuple<int, int, int>> findFirstLinkPositionWithId() {
if (!mpHost || !mpHost->mpConsole) {
return std::nullopt;
}
TMainConsole *console = mpHost->mpConsole;
for (int line = 0; line <= console->buffer.getLastLineNumber(); ++line) {
for (int col = 0; col < console->buffer.line(line).length(); ++col) {
int lid = console->buffer.getLinkIndexAt(line, col);
if (lid > 0) {
return std::tuple{line, col, lid};
}
}
}
return std::nullopt;
}
// Scans forward through the buffer to find the nth distinct hyperlink
// (1-based) and returns its (line, column, linkId), or std::nullopt.
std::optional<std::tuple<int, int, int>> findNthDistinctLinkPosition(int n) {
if (!mpHost || !mpHost->mpConsole) {
return std::nullopt;
}
TMainConsole *console = mpHost->mpConsole;
QSet<int> seenIds;
for (int line = 0; line <= console->buffer.getLastLineNumber(); ++line) {
for (int col = 0; col < console->buffer.line(line).length(); ++col) {
int lid = console->buffer.getLinkIndexAt(line, col);
if (lid > 0 && !seenIds.contains(lid)) {
seenIds.insert(lid);
if (seenIds.size() == n) {
return std::tuple{line, col, lid};
}
}
}
}
return std::nullopt;
}
// Joins all buffer lines into one string.
QString allBufferText() {
TMainConsole *console = mpHost->mpConsole;
QString allText;
for (int i = 0; i <= console->buffer.getLastLineNumber(); ++i) {
allText += console->buffer.line(i);
}
return allText;
}
private slots:
// Start mudlet and create a profile once for all tests.
void initTestCase() {
initializeQRCResourcesForOscTest();
mpServer = new TelnetServerStub(qApp);
mpServer->start(mLocalhost, 0); // ephemeral OS-assigned port avoids collisions across concurrent test runs
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);
const QString path =
mudlet::getMudletPath(enums::profileHomePath, mHostname);
QDir(path).removeRecursively();
QTimer::singleShot(0ms, qApp, [this]() {
mudlet::self()->startAutoLogin({});
QTest::qWait(100ms);
QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button,
Qt::LeftButton);
QTest::qWait(100ms);
QTest::keyClicks(QApplication::focusWidget(), mHostname);
QTest::qWait(100ms);
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab);
QTest::qWait(100ms);
QTest::keyClicks(QApplication::focusWidget(), mLocalhost);
QTest::qWait(100ms);
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab);
QTest::qWait(100ms);
QTest::keyClicks(QApplication::focusWidget(), mPort);
QTest::qWait(100ms);
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 and link state before each test for isolation.
void init() {
QVERIFY(mpHost);
QVERIFY(mpHost->mpConsole);
mpHost->mpConsole->buffer.clear();
}
// Data-driven test: verifies text after various OSC sequences is displayed
// correctly (not swallowed by the parser).
void test_OscTextDisplay_data() {
QTest::addColumn<QString>("message");
QTest::addColumn<QString>("expectedText");
// BEL-terminated OSC 8 splits link text across buffer lines in loopback
// mode, so exact match on the joined text isn't possible - use contains.
QTest::addColumn<bool>("exactMatch");
QTest::newRow("BEL-terminated OSC 2 (window title)")
<< QString("\x1b]2;Window Title\x07Hello World") << qsl("Hello World")
<< true;
QTest::newRow("ST-terminated OSC P (color redefine)")
<< QString("\x1b]P0FF0000\x1b\\Hello") << qsl("Hello") << true;
QTest::newRow("BEL-terminated OSC 8 (hyperlink)")
<< QString(
"\x1b]8;;http://example.com\x07Link Text\x1b]8;;\x07 After Link")
<< qsl("Link Text After Link") << false;
QTest::newRow("BEL-terminated OSC P")
<< QString("\x1b]P0FF0000\x07Hello") << qsl("Hello") << true;
QTest::newRow("empty OSC sequence")
<< QString("\x1b]\x07Normal text") << qsl("Normal text") << true;
QTest::newRow("stray ESC c then literal bracket text")
<< QString("\x1b"
"c[literal] text")
<< qsl("[literal] text") << true;
QTest::newRow("OSC exceeds length limit")
<< QString("\x1b]2;") + QString(5000, 'A') + QString("\x07Normal text")
<< qsl("Normal text") << true;
}
void test_OscTextDisplay() {
QFETCH(QString, message);
QFETCH(QString, expectedText);
QFETCH(bool, exactMatch);
injectData(message);
// Join all buffer lines since cursor position varies with loopback.
TMainConsole *console = mpHost->mpConsole;
QString allText;
for (int i = 0; i <= console->buffer.getLastLineNumber(); ++i) {
allText += console->buffer.line(i);
}
if (exactMatch) {
QCOMPARE(allText.trimmed(), expectedText);
} else {
QVERIFY2(allText.contains(expectedText),
qPrintable(qsl("Expected buffer to contain '%1' but got '%2'")
.arg(expectedText, allText)));
}
}
// =====================================================================
// Split sequence / interleaved local feed tests
// =====================================================================
// A CSI/OSC sequence split across two server packets must still parse when
// locally generated text (a feedTriggers() call, an MMCP chat message)
// arrives in between: the local feed runs through the same parser and must
// not consume or clear the pending ESC latch of the server stream.
void test_SplitSequenceSurvivesInterleavedLocalFeed_data() {
QTest::addColumn<QString>("secondPacket");
QTest::addColumn<QString>("mustNotContain");
QTest::newRow("split CSI") << qsl("[32mAfter\n") << qsl("[32mAfter");
QTest::newRow("split OSC")
<< qsl("]2;Window Title\x07"
"After\n")
<< qsl("]2;Window Title");
}
void test_SplitSequenceSurvivesInterleavedLocalFeed() {
QFETCH(QString, secondPacket);
QFETCH(QString, mustNotContain);
// First server packet ends in a bare ESC...
std::string part1{"Before\n\x1b"};
mpHost->mpConsole->printOnDisplay(part1, true);
// ...then locally generated text arrives in between...
std::string localText{"local tick\n"};
mpHost->mpConsole->printOnDisplay(localText, false);
// ...then the server packet with the rest of the sequence:
std::string part2{secondPacket.toStdString()};
mpHost->mpConsole->printOnDisplay(part2, true);
const QString allText = allBufferText();
QVERIFY2(!allText.contains(mustNotContain),
qPrintable(qsl("Sequence fragment '%1' leaked into display: '%2'")
.arg(mustNotContain, allText)));
QVERIFY2(allText.contains(qsl("After")),
qPrintable(
qsl("Text after the split sequence went missing from: '%1'")
.arg(allText)));
QVERIFY2(allText.contains(qsl("local tick")),
qPrintable(qsl("Locally fed text went missing from: '%1'")
.arg(allText)));
}
// The don't-decode-this-payload flag set by a DCS/SOS/PM/APC introducer
// (mGotString) is carry-over parser state just like mGotOSC and must swap
// with the rest of it around a local feed. If it leaks: a complete OSC in
// the local feed is consumed but silently not decoded, and the server's DCS
// payload is wrongly decoded as an OSC when its terminator arrives.
void test_SplitDcsStringStateDoesNotLeakAcrossChannels() {
const bool savedMayRedefine = mpHost->getMayRedefineColors();
mpHost->setMayRedefineColors(true);
const QColor savedRed = mpHost->mRed;
const QColor savedGreen = mpHost->mGreen;
// First server packet ends inside a DCS (ESC P) with no terminator; its
// payload is shaped like an OSC colour redefinition for ANSI colour 2 so
// that wrongly feeding it to the OSC decoder is observable:
std::string part1{"Before\n\x1bPP2665544"};
mpHost->mpConsole->printOnDisplay(part1, true);
// Interleaved local feed carrying a complete OSC colour redefinition for
// ANSI colour 1 that must be decoded:
std::string localText{"\x1b]P1223344\x07local tick\n"};
mpHost->mpConsole->printOnDisplay(localText, false);
// The server DCS terminator arrives; the payload must be consumed
// without being decoded:
std::string part2{"\x07"
"After\n"};
mpHost->mpConsole->printOnDisplay(part2, true);
const QColor redAfter = mpHost->mRed;
const QColor greenAfter = mpHost->mGreen;
const QString allText = allBufferText();
// Restore profile colour state before asserting so a failure does not
// poison later tests:
mpHost->mRed = savedRed;
mpHost->mGreen = savedGreen;
mpHost->setMayRedefineColors(savedMayRedefine);
QVERIFY2(allText.contains(qsl("After")),
qPrintable(
qsl("Text after the split sequence went missing from: '%1'")
.arg(allText)));
QVERIFY2(allText.contains(qsl("local tick")),
qPrintable(qsl("Locally fed text went missing from: '%1'")
.arg(allText)));
QVERIFY2(!allText.contains(qsl("P2665544")),
qPrintable(qsl("DCS payload leaked into display: '%1'")
.arg(allText)));
QVERIFY2(redAfter == QColor(0x22, 0x33, 0x44),
qPrintable(qsl("Complete OSC inside the local feed was not "
"decoded: expected colour 1 to become #223344 "
"but it is %1")
.arg(redAfter.name())));
QVERIFY2(greenAfter == savedGreen,
qPrintable(qsl("Server DCS payload was wrongly fed to the OSC "
"decoder: colour 2 changed to %1")
.arg(greenAfter.name())));
}
// =====================================================================
// OSC 8 Hyperlink URL Parameter Tests
// =====================================================================
// Data-driven test: verifies OSC 8 URL query parameter handling - reserved
// params (config, preset) are stripped while user params are preserved.
void test_Osc8UrlParams_data() {
QTest::addColumn<QString>("message");
QTest::addColumn<QStringList>("mustContain");
QTest::addColumn<QStringList>("mustNotContain");
QTest::newRow("preserves web URL query params")
<< qsl("\x1b]8;;https://example.com/"
"?id=42&lang=en\x1b\\Link\x1b]8;;\x1b\\")
<< QStringList{"id=42&lang=en"} << QStringList{};
QTest::newRow("strips config param")
<< qsl("\x1b]8;;https://example.com/"
"?config=%7B%22style%22%3A%7B%22color%22%3A%22red%22%7D%"
"7D\x1b\\Styled\x1b]8;;\x1b\\")
<< QStringList{} << QStringList{"config="};
QTest::newRow("strips preset, preserves other params")
<< qsl("\x1b]8;;https://example.com/"
"?page=1&preset=danger\x1b\\Link\x1b]8;;\x1b\\")
<< QStringList{"page=1"} << QStringList{"preset="};
QTest::newRow("strips preset with encoded equals")
<< qsl("\x1b]8;;https://example.com/"
"?preset%3Ddefault&page=1\x1b\\Link\x1b]8;;\x1b\\")
<< QStringList{"page=1"} << QStringList{"preset%3D"};
QTest::newRow("strips config with encoded equals (lowercase)")
<< qsl("\x1b]8;;https://example.com/"
"?config%3dvalue&foo=bar\x1b\\Link\x1b]8;;\x1b\\")
<< QStringList{"foo=bar"} << QStringList{"config%3d"};
QTest::newRow("preserves percent-encoded reserved names")
<< qsl("\x1b]8;;https://example.com/"
"?%63%6F%6E%66%69%67=value\x1b\\Link\x1b]8;;\x1b\\")
<< QStringList{"%63%6F%6E%66%69%67=value"} << QStringList{};
QTest::newRow("send URL strips all query params")
<< qsl("\x1b]8;;send:attack?config=%7B%22style%22%3A%7B%22color%22%3A%"
"22red%22%7D%7D\x1b\\Attack\x1b]8;;\x1b\\")
<< QStringList{"attack"} << QStringList{"config="};
}
void test_Osc8UrlParams() {
QFETCH(QString, message);
QFETCH(QStringList, mustContain);
QFETCH(QStringList, mustNotContain);
injectData(message);
QStringList commands = findFirstLinkCommands();
QVERIFY2(!commands.isEmpty(), "No hyperlink found in buffer");
for (const auto &expected : mustContain) {
QVERIFY2(
commands.first().contains(expected),
qPrintable(
qsl("Expected '%1' in: %2").arg(expected, commands.first())));
}
for (const auto &forbidden : mustNotContain) {
QVERIFY2(!commands.first().contains(forbidden),
qPrintable(qsl("Did not expect '%1' in: %2")
.arg(forbidden, commands.first())));
}
}
// =====================================================================
// OSC 8 Hyperlink Context Menu Title Tests
// =====================================================================
// Data-driven test: verifies menuTitle text is parsed correctly from various
// config JSON shapes (simple string, object, compact syntax, invalid types).
void test_Osc8Title_TextParsing_data() {
QTest::addColumn<QString>("config");
QTest::addColumn<QString>("expectedTitle");
QTest::addColumn<bool>("hasCustomStyling");
QTest::newRow("simple string")
<< qsl(R"({"title":"Lamb and Barley Stew","menu":[{"View Details":"send:look stew"}]})")
<< qsl("Lamb and Barley Stew") << false;
QTest::newRow("compact ti shorthand")
<< qsl(R"({"ti":"Rusty Sword","m":[{"Equip":"send:wield sword"}]})")
<< qsl("Rusty Sword") << false;
QTest::newRow("title without menu")
<< qsl(R"({"title":"Lonely Title"})") << qsl("Lonely Title") << false;
QTest::newRow("menu without title")
<< qsl(R"({"menu":[{"North":"send:north"}]})") << QString() << false;
QTest::newRow("empty string")
<< qsl(R"({"title":"","menu":[{"Action":"send:action"}]})") << QString()
<< false;
QTest::newRow("object with empty text")
<< qsl(R"({"title":{"text":"","style":{"color":"#ff0000"}},"menu":[{"Action":"send:action"}]})")
<< QString() << true;
QTest::newRow("object without text key")
<< qsl(R"({"title":{"style":{"color":"#ff0000"}},"menu":[{"Action":"send:action"}]})")
<< QString() << true;
QTest::newRow("object without style key")
<< qsl(R"({"title":{"text":"Style-less Title"},"menu":[{"Action":"send:action"}]})")
<< qsl("Style-less Title") << false;
QTest::newRow("unicode characters")
<< qsl(R"({"title":"Potion du Guerrier","menu":[{"Drink":"send:drink potion"}]})")
<< qsl("Potion du Guerrier") << false;
QTest::newRow("special characters")
<< qsl(R"({"title":"Item <Rare> [+5] & More!","menu":[{"Use":"send:use item"}]})")
<< qsl("Item <Rare> [+5] & More!") << false;
QTest::newRow("alongside other config")
<< qsl(R"({"title":"Full Config","tooltip":"A helpful tooltip","style":{"color":"#00ffff"},"menu":[{"Action 1":"send:action1"}]})")
<< qsl("Full Config") << true;
QTest::newRow("numeric value ignored")
<< qsl(R"({"title":42,"menu":[{"Action":"send:action"}]})") << QString()
<< false;
QTest::newRow("boolean value ignored")
<< qsl(R"({"title":true,"menu":[{"Action":"send:action"}]})")
<< QString() << false;
QTest::newRow("array value ignored")
<< qsl(R"({"title":["a","b"],"menu":[{"Action":"send:action"}]})")
<< QString() << false;
}
void test_Osc8Title_TextParsing() {
QFETCH(QString, config);
QFETCH(QString, expectedTitle);
QFETCH(bool, hasCustomStyling);
injectData(buildOsc8WithConfig(qsl("send:action"), qsl("[Link]"), config));
// Verify a link was actually created in the buffer - prevents silent
// passes when the injection pipeline is broken entirely.
QStringList commands = findFirstLinkCommands();
QVERIFY2(!commands.isEmpty(), "No hyperlink found in buffer");
auto styling = findFirstLinkStyling();
QCOMPARE(styling.menuTitle, expectedTitle);
if (!hasCustomStyling) {
QVERIFY2(!styling.menuTitleStyle.hasCustomStyling,
"Expected no custom styling on menu title");
}
}
// Data-driven test: verifies menuTitleStyle properties are parsed correctly.
void test_Osc8Title_StyleParsing_data() {
QTest::addColumn<QString>("config");
QTest::addColumn<QString>("expectedTitle");
QTest::addColumn<bool>("bold");
QTest::addColumn<bool>("italic");
QTest::addColumn<bool>("underlined");
QTest::addColumn<bool>("strikeOut");
QTest::addColumn<QString>("fgColor");
QTest::addColumn<QString>("bgColor");
QTest::addColumn<int>("underlineStyle");
QTest::addColumn<QString>("underlineColor");
QTest::newRow("bold and color")
<< qsl(R"({"title":{"text":"Magic Shop - Potions","style":{"color":"#ffd700","bold":true}},"menu":[{"Buy":"send:buy potion"}]})")
<< qsl("Magic Shop - Potions") << true << false << false << false
<< qsl("#ffd700") << QString() << -1 << QString();
QTest::newRow("italic and background")
<< qsl(R"({"title":{"text":"Sir Galahad the Brave","style":{"color":"#ffffff","bg":"#333333","italic":true}},"menu":[{"Talk":"send:talk galahad"}]})")
<< qsl("Sir Galahad the Brave") << false << true << false << false
<< qsl("#ffffff") << qsl("#333333") << -1 << QString();
QTest::newRow("all text decorations")
<< qsl(R"({"title":{"text":"Decorated Title","style":{"color":"#ff0000","bold":true,"italic":true,"underline":true,"strikethrough":true}},"menu":[{"Action":"send:action"}]})")
<< qsl("Decorated Title") << true << true << true << true
<< qsl("#ff0000") << QString() << -1 << QString();
QTest::newRow("wavy underline")
<< qsl(R"({"title":{"text":"Wavy Title","style":{"color":"#00ff00","underline":"wavy"}},"menu":[{"Test":"send:test"}]})")
<< qsl("Wavy Title") << false << false << true << false
<< qsl("#00ff00") << QString()
<< static_cast<int>(Mudlet::HyperlinkStyling::UnderlineWavy)
<< QString();
QTest::newRow("dotted underline")
<< qsl(R"({"title":{"text":"Dotted Title","style":{"color":"#00ff00","underline":"dotted"}},"menu":[{"Test":"send:test"}]})")
<< qsl("Dotted Title") << false << false << true << false
<< qsl("#00ff00") << QString()
<< static_cast<int>(Mudlet::HyperlinkStyling::UnderlineDotted)
<< QString();
QTest::newRow("dashed underline")
<< qsl(R"({"title":{"text":"Dashed Title","style":{"color":"#00ff00","underline":"dashed"}},"menu":[{"Test":"send:test"}]})")
<< qsl("Dashed Title") << false << false << true << false
<< qsl("#00ff00") << QString()
<< static_cast<int>(Mudlet::HyperlinkStyling::UnderlineDashed)
<< QString();
QTest::newRow("background-color CSS property")
<< qsl(R"({"title":{"text":"CSS BG Title","style":{"color":"#ffffff","background-color":"#660000"}},"menu":[{"Action":"send:action"}]})")
<< qsl("CSS BG Title") << false << false << false << false
<< qsl("#ffffff") << qsl("#660000") << -1 << QString();
QTest::newRow("text-decoration-color")
<< qsl(R"({"title":{"text":"Color Decoration","style":{"color":"#ffffff","underline":true,"text-decoration-color":"#ff00ff"}},"menu":[{"Action":"send:action"}]})")
<< qsl("Color Decoration") << false << false << true << false
<< qsl("#ffffff") << QString() << -1 << qsl("#ff00ff");
}
void test_Osc8Title_StyleParsing() {
QFETCH(QString, config);
QFETCH(QString, expectedTitle);
QFETCH(bool, bold);
QFETCH(bool, italic);
QFETCH(bool, underlined);
QFETCH(bool, strikeOut);
QFETCH(QString, fgColor);
QFETCH(QString, bgColor);
QFETCH(int, underlineStyle);
QFETCH(QString, underlineColor);
injectData(buildOsc8WithConfig(qsl("send:action"), qsl("[Link]"), config));
auto styling = findFirstLinkStyling();
QCOMPARE(styling.menuTitle, expectedTitle);
QCOMPARE(styling.menuTitleStyle.isBold, bold);
QCOMPARE(styling.menuTitleStyle.isItalic, italic);
QCOMPARE(styling.menuTitleStyle.isUnderlined, underlined);
QCOMPARE(styling.menuTitleStyle.isStrikeOut, strikeOut);
if (!fgColor.isEmpty()) {
QVERIFY(styling.menuTitleStyle.hasForegroundColor);
QCOMPARE(styling.menuTitleStyle.foregroundColor, QColor(fgColor));
}
if (!bgColor.isEmpty()) {
QVERIFY(styling.menuTitleStyle.hasBackgroundColor);
QCOMPARE(styling.menuTitleStyle.backgroundColor, QColor(bgColor));
}
if (underlineStyle >= 0) {
QCOMPARE(styling.menuTitleStyle.underlineStyle,
static_cast<Mudlet::HyperlinkStyling::UnderlineStyle>(
underlineStyle));
}
if (!underlineColor.isEmpty()) {
QVERIFY(styling.menuTitleStyle.hasUnderlineColor);
QCOMPARE(styling.menuTitleStyle.underlineColor, QColor(underlineColor));
}
}
void test_Osc8Title_LinkTextDisplayedInBuffer() {
QString config = qsl(
R"({"title":"Lamb and Barley Stew","menu":[{"View Details":"send:look stew"}]})");
injectData(buildOsc8WithConfig(qsl("send:look stew"),
qsl("[Lamb and Barley Stew]"), config));
TMainConsole *console = mpHost->mpConsole;
QString allText;
for (int i = 0; i <= console->buffer.getLastLineNumber(); ++i) {
allText += console->buffer.line(i);
}
QVERIFY2(
allText.contains(qsl("[Lamb and Barley Stew]")),
qPrintable(
qsl("Expected link text in buffer but got '%1'").arg(allText)));
}
// =====================================================================
// OSC 8 Hyperlink Navigation Tests (Tab/Shift+Tab support)
// =====================================================================
void test_FindNextLink_MultipleLinks() {
// Inject two distinct links on the same line
injectData(qsl("\x1b]8;;send:north\x1b\\North\x1b]8;;\x1b\\ - "
"\x1b]8;;send:south\x1b\\South\x1b]8;;\x1b\\"));
TMainConsole *console = mpHost->mpConsole;
// Starting from position 0 (on the first link), findNextLink should find
// the second
int outLine = -1, outCol = -1;
bool found = console->buffer.findNextLink(0, 0, outLine, outCol);
QVERIFY2(found, "Expected to find a second link after the first");
// The second link should be at a column past "North - "
int secondLinkIndex = console->buffer.getLinkIndexAt(outLine, outCol);
QVERIFY2(secondLinkIndex > 0,
"Expected a valid link index at the found position");
// Verify it's actually a different link from the first
int firstLinkIndex = console->buffer.getLinkIndexAt(0, 0);
QVERIFY2(
firstLinkIndex != secondLinkIndex,
"findNextLink should return a different link than the starting one");
}
void test_FindNextLink_NoLinks() {
// Inject plain text with no links
injectData(qsl("Just some plain text with no links"));
TMainConsole *console = mpHost->mpConsole;
int outLine = -1, outCol = -1;
bool found = console->buffer.findNextLink(0, 0, outLine, outCol);
QVERIFY2(!found, "findNextLink should return false when no links exist");
}
void test_FindNextLink_SkipsCurrentLink() {
// Inject a single link
injectData(qsl("\x1b]8;;send:look\x1b\\Look around\x1b]8;;\x1b\\"));
TMainConsole *console = mpHost->mpConsole;
// Find where the link starts
auto pos = findFirstLinkPosition();
QVERIFY2(pos.has_value(), "Expected to find a link in the buffer");
auto [linkLine, linkCol] = *pos;
// Starting from the link, there should be no "next" link
int outLine = -1, outCol = -1;
bool found2 =
console->buffer.findNextLink(linkLine, linkCol, outLine, outCol);
QVERIFY2(!found2,
"findNextLink should skip the current link and find nothing else");
}
void test_FindPreviousLink_MultipleLinks() {
// Inject two distinct links
injectData(qsl("\x1b]8;;send:north\x1b\\North\x1b]8;;\x1b\\ - "
"\x1b]8;;send:south\x1b\\South\x1b]8;;\x1b\\"));
TMainConsole *console = mpHost->mpConsole;
// Find the first and second distinct links
auto first = findNthDistinctLinkPosition(1);
QVERIFY2(first.has_value(), "Expected to find a first link in the buffer");
auto [firstLine, firstCol, firstLinkId] = *first;
auto second = findNthDistinctLinkPosition(2);
QVERIFY2(second.has_value(),
"Expected to find a second link in the buffer");
auto [secondLine, secondCol, secondLinkId] = *second;
// From the second link, findPreviousLink should find the first
int outLine = -1, outCol = -1;
bool found = console->buffer.findPreviousLink(secondLine, secondCol,
outLine, outCol);
QVERIFY2(found, "Expected to find the first link when scanning backward "
"from the second");
int foundLinkId = console->buffer.getLinkIndexAt(outLine, outCol);
QCOMPARE(foundLinkId, firstLinkId);
}
void test_FindPreviousLink_AtStartOfBuffer() {
// Inject a single link
injectData(qsl("\x1b]8;;send:look\x1b\\Look\x1b]8;;\x1b\\"));
TMainConsole *console = mpHost->mpConsole;
// Starting from 0,0 there should be no previous link
int outLine = -1, outCol = -1;
bool found = console->buffer.findPreviousLink(0, 0, outLine, outCol);
QVERIFY2(!found,
"findPreviousLink should return false at the start of the buffer");
}
// =====================================================================
// OSC 8 Hyperlink Tooltip Tests
// =====================================================================
void test_GetLinkTooltip_data() {
QTest::addColumn<QString>("config");
QTest::addColumn<QString>("expectedTooltip");
QTest::newRow("tooltip from config")
<< qsl(R"({"tooltip":"A magical sword","style":{"color":"#ff0000"}})")
<< qsl("A magical sword");
QTest::newRow("no tooltip in config - uses default send hint")
<< qsl(R"({"style":{"color":"#00ff00"}})") << qsl("Send: look sword");
QTest::newRow("empty config - uses default send hint")
<< QString() << qsl("Send: look sword");
}
void test_GetLinkTooltip() {
QFETCH(QString, config);
QFETCH(QString, expectedTooltip);
injectData(
buildOsc8WithConfig(qsl("send:look sword"), qsl("[Sword]"), config));
TMainConsole *console = mpHost->mpConsole;
int linkId = 0;
for (int line = console->buffer.getLastLineNumber();
line >= 0 && linkId == 0; --line) {
linkId = console->buffer.getLinkIndexAt(line, 0);
}
QVERIFY2(linkId > 0, "Expected to find a link in the buffer");
QString tooltip = console->buffer.getLinkTooltip(linkId);
QCOMPARE(tooltip, expectedTooltip);
}
void test_GetLinkTooltip_InvalidIndex() {
TMainConsole *console = mpHost->mpConsole;
// Index 0 and negative should return empty
QCOMPARE(console->buffer.getLinkTooltip(0), QString());
QCOMPARE(console->buffer.getLinkTooltip(-1), QString());
// Non-existent positive index should return empty
QCOMPARE(console->buffer.getLinkTooltip(99999), QString());
}
// =====================================================================
// OSC 8 Hyperlink Visited State Tests
// =====================================================================
void test_LinkVisitedState() {
injectData(qsl("\x1b]8;;send:look\x1b\\Look\x1b]8;;\x1b\\"));
TMainConsole *console = mpHost->mpConsole;
int linkId = 0;
for (int line = console->buffer.getLastLineNumber();
line >= 0 && linkId == 0; --line) {
linkId = console->buffer.getLinkIndexAt(line, 0);
}
QVERIFY2(linkId > 0, "Expected to find a link in the buffer");
// Should not be visited initially
QVERIFY2(!console->buffer.isLinkVisited(linkId),
"Link should not be visited initially");
// Mark as visited
console->buffer.markLinkAsVisited(linkId);
QVERIFY2(console->buffer.isLinkVisited(linkId),
"Link should be visited after markLinkAsVisited");
}
// =====================================================================
// OSC 8 Hyperlink Accessible Attributes Tests
// =====================================================================
void test_LinkAttributes_ExposedInAccessible() {
injectData(qsl("\x1b]8;;https://example.com\x1b\\Click me\x1b]8;;\x1b\\"));
TMainConsole *console = mpHost->mpConsole;
// Find the link position in the buffer
auto pos = findFirstLinkPosition();
QVERIFY2(pos.has_value(), "Expected to find a link in the buffer");
auto [linkLine, linkCol] = *pos;
// Create the accessible interface for the upper pane
TTextEdit *pane = console->mUpperPane;
QVERIFY2(pane, "Expected upper pane to exist");
TAccessibleTextEdit accessible(pane);
// Compute the offset for the link position
int offset = 0;
for (int i = 0; i < linkLine; ++i) {
offset += console->buffer.line(i).length() + 1; // +1 for \n
}
offset += linkCol;
// Query attributes at the link offset
int startOffset = 0, endOffset = 0;
QString attrs = accessible.attributes(offset, &startOffset, &endOffset);
// Verify link-specific attributes are present
QVERIFY2(
attrs.contains(qsl("text-link:")),
qPrintable(
qsl("Expected 'text-link:' in attributes but got: %1").arg(attrs)));
QVERIFY2(
attrs.contains(qsl("example.com")),
qPrintable(qsl("Expected URL in attributes but got: %1").arg(attrs)));
}
void test_LinkAttributes_NotPresentOnPlainText() {
injectData(qsl("Just plain text here"));
TMainConsole *console = mpHost->mpConsole;
TTextEdit *pane = console->mUpperPane;
QVERIFY2(pane, "Expected upper pane to exist");
TAccessibleTextEdit accessible(pane);
// Query attributes at position 0 (plain text)
int startOffset = 0, endOffset = 0;
QString attrs = accessible.attributes(0, &startOffset, &endOffset);
// Verify no link attributes are present
QVERIFY2(!attrs.contains(qsl("text-link:")),
qPrintable(
qsl("Did not expect 'text-link:' in plain text attributes: %1")
.arg(attrs)));
}
void test_LinkAttributes_VisitedState() {
injectData(qsl("\x1b]8;;send:look\x1b\\Look\x1b]8;;\x1b\\"));
TMainConsole *console = mpHost->mpConsole;
// Find the link
auto pos = findFirstLinkPositionWithId();
QVERIFY2(pos.has_value(), "Expected to find a link in the buffer");
auto [linkLine, linkCol, linkId] = *pos;
TTextEdit *pane = console->mUpperPane;
QVERIFY(pane);
TAccessibleTextEdit accessible(pane);
int offset = 0;
for (int i = 0; i < linkLine; ++i) {
offset += console->buffer.line(i).length() + 1;
}
offset += linkCol;
// Before visiting: should not have visited attribute
int s = 0, e = 0;
QString attrsBefore = accessible.attributes(offset, &s, &e);
QVERIFY2(!attrsBefore.contains(qsl("text-link-visited:true")),
"Link should not show visited before being visited");
// Mark as visited
console->buffer.markLinkAsVisited(linkId);
// After visiting: should have visited attribute
QString attrsAfter = accessible.attributes(offset, &s, &e);
QVERIFY2(
attrsAfter.contains(qsl("text-link-visited:true")),
qPrintable(
qsl("Expected 'text-link-visited:true' after visiting but got: %1")
.arg(attrsAfter)));
}
void test_LinkAttributes_DisabledState() {
injectData(qsl("\x1b]8;;send:look\x1b\\Disabled Link\x1b]8;;\x1b\\"));
TMainConsole *console = mpHost->mpConsole;
auto pos = findFirstLinkPositionWithId();
QVERIFY2(pos.has_value(), "Expected to find a link in the buffer");
auto [linkLine, linkCol, linkId] = *pos;
// Set link state to disabled
console->buffer.setLinkState(linkId,
Mudlet::HyperlinkStyling::StateDisabled);
TTextEdit *pane = console->mUpperPane;
QVERIFY(pane);
TAccessibleTextEdit accessible(pane);
int offset = 0;
for (int i = 0; i < linkLine; ++i) {
offset += console->buffer.line(i).length() + 1;
}
offset += linkCol;
int s = 0, e = 0;
QString attrs = accessible.attributes(offset, &s, &e);
QVERIFY2(
attrs.contains(qsl("text-link-disabled:true;")),
qPrintable(
qsl("Expected 'text-link-disabled:true;' in attributes but got: %1")
.arg(attrs)));
}
void test_LinkAttributes_SelectedState() {
injectData(qsl("\x1b]8;;send:look\x1b\\Select Me\x1b]8;;\x1b\\"));
TMainConsole *console = mpHost->mpConsole;
auto pos = findFirstLinkPositionWithId();
QVERIFY2(pos.has_value(), "Expected to find a link in the buffer");
auto [linkLine, linkCol, linkId] = *pos;
TTextEdit *pane = console->mUpperPane;
QVERIFY(pane);
TAccessibleTextEdit accessible(pane);
int offset = 0;
for (int i = 0; i < linkLine; ++i) {
offset += console->buffer.line(i).length() + 1;
}
offset += linkCol;
// Before selecting: should not have selected attribute
int s = 0, e = 0;
QString attrsBefore = accessible.attributes(offset, &s, &e);
QVERIFY2(!attrsBefore.contains(qsl("text-link-selected:true")),
"Link should not show selected before being selected");
// Mark as selected
console->buffer.setLinkSelected(linkId, true);
// After selecting: should have selected attribute
QString attrsAfter = accessible.attributes(offset, &s, &e);
QVERIFY2(attrsAfter.contains(qsl("text-link-selected:true;")),
qPrintable(qsl("Expected 'text-link-selected:true;' after "
"selecting but got: %1")
.arg(attrsAfter)));
}
void test_LinkAttributes_UrlSanitization() {
injectData(
qsl("\x1b]8;;https://example.com:8080/path\x1b\\Look\x1b]8;;\x1b\\"));
TMainConsole *console = mpHost->mpConsole;
auto pos = findFirstLinkPosition();
QVERIFY2(pos.has_value(), "Expected to find a link in the buffer");
auto [linkLine, linkCol] = *pos;
TTextEdit *pane = console->mUpperPane;
QVERIFY(pane);
TAccessibleTextEdit accessible(pane);
int offset = 0;
for (int i = 0; i < linkLine; ++i) {
offset += console->buffer.line(i).length() + 1;
}
offset += linkCol;
int s = 0, e = 0;
QString attrs = accessible.attributes(offset, &s, &e);
// Colons in the URL should be escaped as \:
QVERIFY2(attrs.contains(qsl("\\:")),
qPrintable(qsl("Expected escaped colons (\\:) in text-link "
"attribute but got: %1")
.arg(attrs)));
// Verify the attribute does not contain an unescaped colon after
// "text-link:" The "text-link:" prefix itself has an unescaped colon, but
// colons within the URL value must be escaped
int linkAttrStart = attrs.indexOf(qsl("text-link:"));
QVERIFY(linkAttrStart >= 0);
QString linkValue = attrs.mid(linkAttrStart + 10); // after "text-link:"
int semiPos = linkValue.indexOf(QLatin1Char(';'));
QVERIFY(semiPos > 0);
linkValue = linkValue.left(semiPos);
// Every colon in the value should be preceded by a backslash
for (int i = 0; i < linkValue.size(); ++i) {
if (linkValue.at(i) == QLatin1Char(':')) {
QVERIFY2(i > 0 && linkValue.at(i - 1) == QLatin1Char('\\'),
qPrintable(qsl("Found unescaped colon in text-link value: %1")
.arg(linkValue)));
}
}
}
void test_LinkAttributes_SemicolonAndBackslashEscaping() {
// URL contains semicolons and backslashes that must be escaped in
// IAccessible2 text attributes
injectData(qsl("\x1b]8;;https://example.com/"
"path;param\\extra\x1b\\Look\x1b]8;;\x1b\\"));
TMainConsole *console = mpHost->mpConsole;
auto pos = findFirstLinkPosition();
QVERIFY2(pos.has_value(), "Expected to find a link in the buffer");
auto [linkLine, linkCol] = *pos;
TTextEdit *pane = console->mUpperPane;
QVERIFY(pane);
TAccessibleTextEdit accessible(pane);
int offset = 0;
for (int i = 0; i < linkLine; ++i) {
offset += console->buffer.line(i).length() + 1;
}
offset += linkCol;
int s = 0, e = 0;
QString attrs = accessible.attributes(offset, &s, &e);
// Extract the text-link value, skipping escaped semicolons (\;)
int linkAttrStart = attrs.indexOf(qsl("text-link:"));
QVERIFY(linkAttrStart >= 0);
QString linkValue = attrs.mid(linkAttrStart + 10);
int semiEnd = -1;
for (int i = 0; i < linkValue.length(); ++i) {
if (linkValue.at(i) == QLatin1Char(';') &&
(i == 0 || linkValue.at(i - 1) != QLatin1Char('\\'))) {
semiEnd = i;
break;
}
}
QVERIFY(semiEnd > 0);
linkValue = linkValue.left(semiEnd);
// Semicolons within the URL value should be escaped as \;
QVERIFY2(linkValue.contains(qsl("\\;")),
qPrintable(qsl("Expected escaped semicolons in text-link "
"value but got: %1")
.arg(linkValue)));
// Backslashes within the URL value should be escaped as double backslash
const QString escapedBackslash = qsl("\\\\");
QVERIFY2(linkValue.contains(escapedBackslash),
qPrintable(qsl("Expected escaped backslashes in text-link "
"value but got: %1")
.arg(linkValue)));
}
void test_LinkAttributes_GroupAttribute() {
// Link with a selection group should expose text-link-group attribute
QString config = qsl(
R"({"selection":{"group":"combat","value":"sword","toggle":true}})");
injectData(
buildOsc8WithConfig(qsl("send:equip sword"), qsl("[Sword]"), config));
TMainConsole *console = mpHost->mpConsole;
auto pos = findFirstLinkPosition();
QVERIFY2(pos.has_value(), "Expected to find a link in the buffer");
auto [linkLine, linkCol] = *pos;
TTextEdit *pane = console->mUpperPane;
QVERIFY(pane);
TAccessibleTextEdit accessible(pane);
int offset = 0;
for (int i = 0; i < linkLine; ++i) {
offset += console->buffer.line(i).length() + 1;
}
offset += linkCol;
int s = 0, e = 0;
QString attrs = accessible.attributes(offset, &s, &e);
QVERIFY2(attrs.contains(qsl("text-link-group:")),
qPrintable(qsl("Expected 'text-link-group:' in attributes "
"but got: %1")
.arg(attrs)));
// The group value should contain the group name (may be escaped)
QVERIFY2(attrs.contains(qsl("combat")),
qPrintable(qsl("Expected group name 'combat' in attributes "
"but got: %1")
.arg(attrs)));
}
// =====================================================================
// OSC 8 Hyperlink QAccessible::Description Tests
// =====================================================================
void test_Description_CaretOnLink() {
QString config = qsl(
R"({"tooltip":"A magical sword","menu":[{"View Details":"send:look sword"}]})");
injectData(
buildOsc8WithConfig(qsl("send:look sword"), qsl("[Sword]"), config));
TMainConsole *console = mpHost->mpConsole;
// Find the link position
auto pos = findFirstLinkPosition();
QVERIFY2(pos.has_value(), "Expected to find a link in the buffer");
auto [linkLine, linkCol] = *pos;
// Enable caret mode and move caret to the link
mpHost->setCaretEnabled(true);
TTextEdit *pane = console->mUpperPane;
QVERIFY(pane);
pane->setCaretPosition(linkLine, linkCol);
TAccessibleTextEdit accessible(pane);
QString desc = accessible.text(QAccessible::Description);
QVERIFY2(
desc.contains(qsl("A magical sword")),
qPrintable(
qsl("Expected tooltip in Description but got: '%1'").arg(desc)));
// Cleanup: disable caret mode
mpHost->setCaretEnabled(false);
}
void test_Description_CaretNotOnLink() {
injectData(qsl("Plain text with no links"));
TMainConsole *console = mpHost->mpConsole;
// Enable caret mode, caret at line 0 col 0 (plain text)
mpHost->setCaretEnabled(true);
TTextEdit *pane = console->mUpperPane;
QVERIFY(pane);
pane->setCaretPosition(0, 0);
TAccessibleTextEdit accessible(pane);
QString desc = accessible.text(QAccessible::Description);
QVERIFY(desc.isEmpty());
// Cleanup
mpHost->setCaretEnabled(false);
}
// =====================================================================
// OSC 8 attributes() run-range / caret-cell isolation Tests
// =====================================================================
// Helper: compute the offset of a (line, column) position the same way the
// accessible interface does it (one '\n' per line break).
int offsetForPosition(TMainConsole *console, int line, int column) {
int offset = 0;
for (int i = 0; i < line; ++i) {
offset += console->buffer.line(i).length() + 1;
}
return offset + column;
}
void test_Attributes_RunRangeCoversFullLink() {
// A whole link run should be returned in a single attributes() call
// (start..end describe the contiguous range with these attributes).
injectData(qsl("\x1b]8;;https://example.com\x1b\\Click me\x1b]8;;\x1b\\"));
TMainConsole *console = mpHost->mpConsole;
auto pos = findFirstLinkPositionWithId();
QVERIFY2(pos.has_value(), "Expected to find a link in the buffer");
auto [linkLine, linkCol, linkId] = *pos;
TTextEdit *pane = console->mUpperPane;
QVERIFY(pane);
TAccessibleTextEdit accessible(pane);
const int offset = offsetForPosition(console, linkLine, linkCol);
int startOffset = -1, endOffset = -1;
QString attrs = accessible.attributes(offset, &startOffset, &endOffset);
QVERIFY2(attrs.contains(qsl("text-link:")),
qPrintable(qsl("Expected link attributes; got: %1").arg(attrs)));
// The reported range must include the queried offset and stay within the
// link span on this line.
QVERIFY2(startOffset <= offset && offset < endOffset,
qPrintable(qsl("Range [%1,%2) must include offset %3")
.arg(startOffset)
.arg(endOffset)
.arg(offset)));
// Every column inside [startCol, endCol) on this line must belong to the
// same linkId, otherwise the run-expansion crossed a boundary.
const int lineStart = offsetForPosition(console, linkLine, 0);
for (int o = startOffset; o < endOffset; ++o) {
const int col = o - lineStart;
QVERIFY2(col >= 0 && col < console->buffer.line(linkLine).length(),
"run range should not cross a line boundary");
QCOMPARE(console->buffer.getLinkIndexAt(linkLine, col), linkId);
}
}
void test_Attributes_RunRangeStopsAtLinkBoundary() {
// " " between "X" and the link must not be merged with the link's run.
injectData(qsl("X \x1b]8;;https://example.com\x1b\\L\x1b]8;;\x1b\\"));
TMainConsole *console = mpHost->mpConsole;
auto pos = findFirstLinkPositionWithId();
QVERIFY2(pos.has_value(), "Expected to find a link in the buffer");
auto [linkLine, linkCol, linkId] = *pos;
QVERIFY2(linkCol >= 1, "Link should be preceded by plain text");
TTextEdit *pane = console->mUpperPane;
QVERIFY(pane);
TAccessibleTextEdit accessible(pane);
// Query attributes at the plain-text character immediately before the link.
const int plainOffset = offsetForPosition(console, linkLine, linkCol - 1);
int s = -1, e = -1;
QString plainAttrs = accessible.attributes(plainOffset, &s, &e);
QVERIFY2(!plainAttrs.contains(qsl("text-link:")),
"Plain text before link should not have link attributes");
// The run from the plain-text query must end at or before the link starts.
const int linkOffset = offsetForPosition(console, linkLine, linkCol);
QVERIFY2(e <= linkOffset,
qPrintable(qsl("Plain run [%1,%2) leaked into link starting at %3")
.arg(s)
.arg(e)
.arg(linkOffset)));
}
void test_Attributes_CaretCellIsolatedFromRun() {
// The caret cell renders inverted; it must not be merged into a
// neighbouring run even when they otherwise share styling.
injectData(qsl("\x1b]8;;https://example.com\x1b\\Hello\x1b]8;;\x1b\\"));
TMainConsole *console = mpHost->mpConsole;
auto pos = findFirstLinkPositionWithId();
QVERIFY2(pos.has_value(), "Expected to find a link in the buffer");
auto [linkLine, linkCol, linkId] = *pos;
mpHost->setCaretEnabled(true);
TTextEdit *pane = console->mUpperPane;
QVERIFY(pane);
// Park the caret on the second character of the link.
pane->setCaretPosition(linkLine, linkCol + 1);
TAccessibleTextEdit accessible(pane);
const int caretOffset = offsetForPosition(console, linkLine, linkCol + 1);
int sCaret = -1, eCaret = -1;
QString attrsCaret = accessible.attributes(caretOffset, &sCaret, &eCaret);
QVERIFY2(attrsCaret.contains(qsl("text-link:")),
"Caret-on-link should still report link attributes");
// The caret cell's range must be just the caret cell, not the whole link.
QCOMPARE(eCaret - sCaret, 1);
// The character before the caret must form its own run that ends at the
// caret cell (does not engulf it).
const int beforeOffset = offsetForPosition(console, linkLine, linkCol);
int sBefore = -1, eBefore = -1;
accessible.attributes(beforeOffset, &sBefore, &eBefore);
QVERIFY2(eBefore == caretOffset,
qPrintable(qsl("Run before caret should end at caret offset %1, "
"got [%2,%3)")
.arg(caretOffset)
.arg(sBefore)
.arg(eBefore)));
mpHost->setCaretEnabled(false);
}
// =====================================================================
// OSC 8 link navigation key Tests
// =====================================================================
void test_FindLink_WrapForward() {
// With only one link in the buffer, starting from after it we should wrap
// to the start and rediscover the same link (and report wrapped == true).
injectData(qsl("\x1b]8;;send:look\x1b\\Look\x1b]8;;\x1b\\ trailing text"));
TMainConsole *console = mpHost->mpConsole;
auto pos = findFirstLinkPositionWithId();
QVERIFY2(pos.has_value(), "Expected to find a link in the buffer");
auto [linkLine, linkCol, linkId] = *pos;
Q_UNUSED(linkLine);
Q_UNUSED(linkCol);
// Scan forward starting at a position past the only link: the search must
// fall through to the wrap-around branch and rediscover the same link.
const int lastLine = console->buffer.getLastLineNumber();
const int lastCol = console->buffer.line(lastLine).length() - 1;
int outLine2 = -1, outCol2 = -1;
bool wrapped2 = false;
bool found2 = console->buffer.findNextLink(lastLine, lastCol, outLine2,
outCol2, &wrapped2);
QVERIFY2(found2, "findNextLink with wrap should rediscover the only link");
QVERIFY2(wrapped2, "wrapped should be set to true when the search wrapped");
QCOMPARE(console->buffer.getLinkIndexAt(outLine2, outCol2), linkId);
}
void test_FindLink_WrapBackward() {
// Place the link on a later line so that a backward search from line 0
// exhausts the pre-start range and falls through to the wrap path.
injectData(qsl("plain leading line"));
injectData(qsl("\x1b]8;;send:look\x1b\\Look\x1b]8;;\x1b\\"));
TMainConsole *console = mpHost->mpConsole;
auto pos = findFirstLinkPositionWithId();
QVERIFY2(pos.has_value(), "Expected to find a link in the buffer");
auto [linkLine, linkCol, linkId] = *pos;
QVERIFY2(linkLine > 0,
"Test setup expects the link on a line after the leading text");
Q_UNUSED(linkCol);
// Searching backward from the very start of the buffer must wrap to find
// the only link further down the buffer.
int outLine = -1, outCol = -1;
bool wrapped = false;
bool found =
console->buffer.findPreviousLink(0, 0, outLine, outCol, &wrapped);
QVERIFY2(found,
"findPreviousLink with wrap should rediscover the only link");
QVERIFY2(wrapped,
"wrapped should be set to true when backward search wrapped");
QCOMPARE(outLine, linkLine);
QCOMPARE(console->buffer.getLinkIndexAt(outLine, outCol), linkId);
}
void test_KeyNav_CtrlBracketRight_MovesToNextLink() {
injectData(qsl("\x1b]8;;send:north\x1b\\North\x1b]8;;\x1b\\ - "
"\x1b]8;;send:south\x1b\\South\x1b]8;;\x1b\\"));
TMainConsole *console = mpHost->mpConsole;
auto first = findNthDistinctLinkPosition(1);
auto second = findNthDistinctLinkPosition(2);
QVERIFY(first.has_value() && second.has_value());
auto [firstLine, firstCol, firstLinkId] = *first;
auto [secondLine, secondCol, secondLinkId] = *second;
Q_UNUSED(firstLinkId);
mpHost->setCaretEnabled(true);
TTextEdit *pane = console->mUpperPane;
QVERIFY(pane);
pane->setCaretPosition(firstLine, firstCol);
QKeyEvent press(QEvent::KeyPress, Qt::Key_BracketRight,
Qt::ControlModifier);
QApplication::sendEvent(pane, &press);
QCOMPARE(pane->mCaretLine, secondLine);
QCOMPARE(pane->mCaretColumn, secondCol);
QCOMPARE(console->buffer.getFocusedLink(), secondLinkId);
mpHost->setCaretEnabled(false);
}
void test_KeyNav_CtrlBracketLeft_MovesToPreviousLink() {
injectData(qsl("\x1b]8;;send:north\x1b\\North\x1b]8;;\x1b\\ - "
"\x1b]8;;send:south\x1b\\South\x1b]8;;\x1b\\"));
TMainConsole *console = mpHost->mpConsole;
auto first = findNthDistinctLinkPosition(1);
auto second = findNthDistinctLinkPosition(2);
QVERIFY(first.has_value() && second.has_value());
auto [firstLine, firstCol, firstLinkId] = *first;
auto [secondLine, secondCol, secondLinkId] = *second;
Q_UNUSED(secondLinkId);
mpHost->setCaretEnabled(true);
TTextEdit *pane = console->mUpperPane;
QVERIFY(pane);
pane->setCaretPosition(secondLine, secondCol);
QKeyEvent press(QEvent::KeyPress, Qt::Key_BracketLeft, Qt::ControlModifier);
QApplication::sendEvent(pane, &press);
QCOMPARE(pane->mCaretLine, firstLine);
QCOMPARE(pane->mCaretColumn, firstCol);
QCOMPARE(console->buffer.getFocusedLink(), firstLinkId);
mpHost->setCaretEnabled(false);
}
void cleanupTestCase() {
delete mpServer;
mpServer = nullptr;
mpHost = nullptr;
const QString path =
mudlet::getMudletPath(enums::profileHomePath, mHostname);
QDir(path).removeRecursively();
delete mudlet::self();
}
};
void initializeQRCResourcesForOscTest() {
#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 "TOscTest.moc"
QTEST_MAIN(TOscTest)