mudlet/test/functional_tests/WindowBackgroundTest.cpp
Vadim Peretokin 8a8325a6af
fix: three full-window background bugs - vanishing console, lost border colour, huge cover scaling (#9711)
#### Brief overview of PR changes/additions
- `lowerWindow()` moved the main display below the full-window
background widget, so with a background set the whole console (text,
split, scrollbar, command line) vanished for the rest of the session; it
now keeps the background bottom-most.
- `setBorderColor` lived only in `mpMainFrame`'s palette, which
`changeColors()` rebuilds from constants - a game sending an OSC palette
change wiped it with no user action at all - and `getBorderColor()`
returned `0,0,0` under a full-window background. The colour is now
stored on the console.
- `cover` mode scaled the whole source before cropping, so a 3000x100
image in a 1920x1080 window built a 32400x1080 (~140MB) intermediate on
every resize event; it now crops to the target aspect first. Measured
133.5MB/15ms to 0.1MB/3ms, and 890MB for a 20000x100 source.

#### Motivation for adding to Mudlet
All three are in the 5.0 full-window background feature and the first
one makes an ordinary `lowerWindow()` call blank the entire console.

#### Other info (issues closed, discussion etc)
Finding C10 of the 5.0 QA sweep. Introduced by `ae6b017c8` "add:
full-window background image/gradient support" (#9394).

`raiseWindow("main")` and `lowerWindow("main")` both return false today
(`"main"` is never registered in any of the window maps), so there was
no way to undo the first bug from a script - verified, not changed here.

New `WindowBackgroundTest` (17 cases). Five of its assertions were
confirmed to fail before the fix and pass after; the crop-then-scale
order is pinned by comparing the installed brush against a crop-first
render, which a scale-first implementation fails.

Assisted-by: Claude:claude-opus-5

**Test case:** `setBackgroundImage("main",
getMudletHomeDir().."/bg.png", "cover", true)` then `createLabel("l",
10, 10, 100, 100, 1)` then `lowerWindow("l")` - the console stays
visible.
2026-08-07 10:15:11 +02:00

435 lines
18 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. *
***************************************************************************/
#include <QSignalSpy>
#include <QtTest/QtTest>
#include <chrono>
#include "Host.h"
#include "MudletInstanceCoordinator.h"
#include "TLabel.h"
#include "TLuaInterpreter.h"
#include "TMainConsole.h"
#include "TelnetServerStub.h"
#include "ctelnet.h"
#include "dlgConnectionProfiles.h"
#include "mudlet.h"
extern "C" {
#if defined(INCLUDE_VERSIONED_LUA_HEADERS)
#include <lua5.1/lua.h>
#else
#include <lua.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 initializeQRCResourcesForWindowBackgroundTest();
// Covers the full-window background feature added in #9394.
class WindowBackgroundTest : public QObject
{
Q_OBJECT
private:
TelnetServerStub* mpServer = nullptr;
Host* mpHost = nullptr;
const QString mHostname = "WindowBackground-Test-Host";
QString mPort;
const QString mLocalhost = "localhost";
QTemporaryDir mImageDir;
// a pattern rather than a flat fill, so that resampling differences show up
QString writeImage(const QString& fileName, const QSize& size, const QColor& seed)
{
QImage image(size, QImage::Format_ARGB32);
for (int y = 0; y < size.height(); ++y) {
for (int x = 0; x < size.width(); ++x) {
image.setPixel(x, y, qRgb((seed.red() + x * 7) % 256, (seed.green() + y * 13) % 256, (seed.blue() + (x + y) * 3) % 256));
}
}
const QString path = mImageDir.filePath(fileName);
if (!image.save(path, "PNG")) {
return QString();
}
return path;
}
void runLua(const QString& script) { QVERIFY2(mpHost->getLuaInterpreter()->compileAndExecuteScript(script), qPrintable(script)); }
int luaInt(const QString& global)
{
lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState();
lua_getglobal(L, global.toUtf8().constData());
const int value = static_cast<int>(lua_tointeger(L, -1));
lua_pop(L, 1);
return value;
}
QString luaString(const QString& global)
{
lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState();
lua_getglobal(L, global.toUtf8().constData());
const QString value = QString::fromUtf8(lua_tostring(L, -1));
lua_pop(L, 1);
return value;
}
bool luaNil(const QString& global)
{
lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState();
lua_getglobal(L, global.toUtf8().constData());
const bool value = lua_isnil(L, -1);
lua_pop(L, 1);
return value;
}
int stackIndex(const QWidget* widget) const { return mpHost->mpConsole->mpMainFrame->children().indexOf(widget); }
void verifyStackedBelow(const QWidget* lower, const QWidget* upper, const char* message)
{
QVERIFY(lower);
QVERIFY(upper);
const int lowerIndex = stackIndex(lower);
const int upperIndex = stackIndex(upper);
QVERIFY2(lowerIndex >= 0 && upperIndex >= 0, "a widget under test is not a child of mpMainFrame");
QVERIFY2(lowerIndex < upperIndex, message);
}
QPixmap installedBackgroundBrush() const { return mpHost->mpConsole->mpWindowBackground->palette().brush(QPalette::Window).texture(); }
private slots:
void initTestCase()
{
initializeQRCResourcesForWindowBackgroundTest();
QVERIFY(mImageDir.isValid());
mpServer = new TelnetServerStub(qApp);
mpServer->start(mLocalhost, 0);
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);
QDir(mudlet::getMudletPath(enums::profileHomePath, mHostname)).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(5000)) {
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(2000)) {
QFAIL("Could not connect with the host.");
}
mudlet::self()->resize(1200, 800);
QTest::qWait(100ms);
}
void cleanupTestCase()
{
delete mpServer;
mpServer = nullptr;
mpHost = nullptr;
const QString path = mudlet::getMudletPath(enums::profileHomePath, mHostname);
delete mudlet::self();
QDir(path).removeRecursively();
}
void init()
{
QVERIFY(mpHost);
QVERIFY(mpHost->mpConsole);
QVERIFY(mpHost->mpConsole->mpWindowBackground);
runLua(qsl("resetBackgroundImage('main', true)"));
QCOMPARE(mpHost->mpConsole->mWindowBgImageMode, 0);
runLua(qsl("setBorderColor(0, 0, 0)"));
}
// runs even when a QVERIFY aborts a test body, so nothing leaks into the next one
void cleanup()
{
mpHost->mpConsole->deleteLabel(qsl("lowerTarget"));
mpHost->mpConsole->deleteMiniConsole(qsl("lowerConsole"));
}
// lowerWindow() drops mpMainDisplay to the bottom of mpMainFrame's stack so a
// lowered label still sits above the console - and the background is a sibling there.
void test_lowerWindowKeepsWindowBackgroundBottomMost()
{
const QString imagePath = writeImage(qsl("solid.png"), QSize(64, 64), Qt::red);
QVERIFY(!imagePath.isEmpty());
runLua(qsl("setBackgroundImage('main', [[%1]], 'cover', true)").arg(imagePath));
QCOMPARE(mpHost->mpConsole->mWindowBgImageMode, 5);
runLua(qsl("createLabel('lowerTarget', 10, 10, 100, 100, 1)"));
QVERIFY(mpHost->mpConsole->mLabelMap.contains(qsl("lowerTarget")));
runLua(qsl("lowerWindow('lowerTarget')"));
verifyStackedBelow(mpHost->mpConsole->mpWindowBackground, mpHost->mpConsole->mpMainDisplay, "lowerWindow() left the full-window background painting on top of the main display");
verifyStackedBelow(mpHost->mpConsole->mpMainDisplay, mpHost->mpConsole->mLabelMap.value(qsl("lowerTarget")), "lowerWindow() left the lowered label hidden behind the main display");
}
// The six branches of lowerWindow() are copy-pasted, so cover a second one.
void test_lowerWindowKeepsWindowBackgroundBottomMostForAMiniConsole()
{
const QString imagePath = writeImage(qsl("solidConsole.png"), QSize(64, 64), Qt::cyan);
QVERIFY(!imagePath.isEmpty());
runLua(qsl("setBackgroundImage('main', [[%1]], 'cover', true)").arg(imagePath));
runLua(qsl("createMiniConsole('lowerConsole', 10, 10, 200, 100)"));
QVERIFY(mpHost->mpConsole->mSubConsoleMap.contains(qsl("lowerConsole")));
runLua(qsl("lowerWindow('lowerConsole')"));
verifyStackedBelow(mpHost->mpConsole->mpWindowBackground, mpHost->mpConsole->mpMainDisplay, "lowerWindow() left the full-window background painting on top of the main display");
}
void test_lowerWindowOrderingHoldsWithoutABackgroundImage()
{
runLua(qsl("createLabel('lowerTarget', 10, 10, 100, 100, 1)"));
runLua(qsl("lowerWindow('lowerTarget')"));
verifyStackedBelow(mpHost->mpConsole->mpWindowBackground, mpHost->mpConsole->mpMainDisplay, "lowerWindow() put the main display below the full-window background widget");
verifyStackedBelow(mpHost->mpConsole->mpMainDisplay, mpHost->mpConsole->mLabelMap.value(qsl("lowerTarget")), "lowerWindow() left the lowered label hidden behind the main display");
}
// a game can reach changeColors() with no user action, through an OSC palette change
void test_borderColorSurvivesChangeColors()
{
runLua(qsl("setBorderColor(10, 20, 30)"));
QCOMPARE(mpHost->mpConsole->mpMainFrame->palette().color(QPalette::Window), QColor(10, 20, 30));
mpHost->mpConsole->changeColors();
QCOMPARE(mpHost->mpConsole->mpMainFrame->palette().color(QPalette::Window), QColor(10, 20, 30));
QCOMPARE(mpHost->mpConsole->borderColor(), QColor(10, 20, 30));
}
void test_borderColorSurvivesSetBackgroundColor()
{
runLua(qsl("setBorderColor(40, 50, 60)"));
runLua(qsl("setBackgroundColor('main', 1, 2, 3, 255)"));
QCOMPARE(mpHost->mpConsole->mpMainFrame->palette().color(QPalette::Window), QColor(40, 50, 60));
}
void test_borderColorReturnsAfterResettingTheBackground()
{
const QString imagePath = writeImage(qsl("reset.png"), QSize(64, 64), Qt::yellow);
QVERIFY(!imagePath.isEmpty());
runLua(qsl("setBorderColor(255, 0, 0)"));
runLua(qsl("setBackgroundImage('main', [[%1]], 'cover', true)").arg(imagePath));
QCOMPARE(mpHost->mpConsole->mpMainFrame->palette().color(QPalette::Window).alpha(), 0);
runLua(qsl("resetBackgroundImage('main', true)"));
QCOMPARE(mpHost->mpConsole->mpMainFrame->palette().color(QPalette::Window), QColor(255, 0, 0));
}
void test_setBorderColorUnderAFullWindowBackgroundKeepsTheFrameTransparent()
{
const QString imagePath = writeImage(qsl("order.png"), QSize(64, 64), Qt::white);
QVERIFY(!imagePath.isEmpty());
runLua(qsl("setBackgroundImage('main', [[%1]], 'cover', true)").arg(imagePath));
runLua(qsl("setBorderColor(11, 22, 33)"));
QCOMPARE(mpHost->mpConsole->mpMainFrame->palette().color(QPalette::Window).alpha(), 0);
QCOMPARE(mpHost->mpConsole->borderColor(), QColor(11, 22, 33));
}
// the frame is transparent under a full-window background, so the palette cannot be the source
void test_getBorderColorReportsSetValueUnderFullWindowBackground()
{
const QString imagePath = writeImage(qsl("solid2.png"), QSize(64, 64), Qt::blue);
QVERIFY(!imagePath.isEmpty());
runLua(qsl("setBorderColor(70, 80, 90)"));
runLua(qsl("setBackgroundImage('main', [[%1]], 'cover', true)").arg(imagePath));
runLua(qsl("borderR, borderG, borderB = getBorderColor()"));
QCOMPARE(luaInt(qsl("borderR")), 70);
QCOMPARE(luaInt(qsl("borderG")), 80);
QCOMPARE(luaInt(qsl("borderB")), 90);
QCOMPARE(mpHost->mpConsole->mpMainFrame->palette().color(QPalette::Window).alpha(), 0);
}
void test_coverSourceRectNeverExceedsTheSourceImage()
{
const QVector<QSize> sourceSizes{{3000, 100}, {100, 3000}, {1920, 1080}, {64, 64}, {1, 4000}, {4000, 1}};
const QVector<QSize> targetSizes{{1920, 1080}, {800, 600}, {1, 1}, {3840, 40}};
for (const QSize& source : sourceSizes) {
for (const QSize& target : targetSizes) {
const QRect crop = TConsole::coverSourceRect(source, target);
const QString context = qsl("source %1x%2 target %3x%4").arg(source.width()).arg(source.height()).arg(target.width()).arg(target.height());
QVERIFY2(!crop.isEmpty(), qPrintable(context));
QVERIFY2(QRect(QPoint(0, 0), source).contains(crop), qPrintable(context));
}
}
}
void test_coverSourceRectMatchesAspectPreservingCentreCrop()
{
const QRect wideSource = TConsole::coverSourceRect(QSize(3000, 100), QSize(1920, 1080));
QCOMPARE(wideSource.height(), 100);
QCOMPARE(wideSource.width(), 177);
QCOMPARE(wideSource.center().x(), QRect(0, 0, 3000, 100).center().x());
const QRect tallSource = TConsole::coverSourceRect(QSize(100, 3000), QSize(1920, 1080));
QCOMPARE(tallSource.width(), 100);
QCOMPARE(tallSource.height(), 56);
QCOMPARE(tallSource.center().y(), QRect(0, 0, 100, 3000).center().y());
}
void test_coverBrushMatchesWidgetSizeForExtremeAspectImage()
{
const QString imagePath = writeImage(qsl("wide.png"), QSize(3000, 100), Qt::green);
QVERIFY(!imagePath.isEmpty());
runLua(qsl("setBackgroundImage('main', [[%1]], 'cover', true)").arg(imagePath));
const QSize widgetSize = mpHost->mpConsole->mpWindowBackground->size();
QVERIFY(!widgetSize.isEmpty());
QCOMPARE(installedBackgroundBrush().size(), widgetSize);
}
// the two orders resample differently, so this fails if the crop stops coming first
void test_coverBrushIsScaledFromTheCroppedSourceRegion()
{
const QSize sourceSize(3000, 100);
const QString imagePath = writeImage(qsl("order-wide.png"), sourceSize, Qt::darkGreen);
QVERIFY(!imagePath.isEmpty());
runLua(qsl("setBackgroundImage('main', [[%1]], 'cover', true)").arg(imagePath));
const QSize widgetSize = mpHost->mpConsole->mpWindowBackground->size();
const QPixmap source(imagePath);
QCOMPARE(source.size(), sourceSize);
const QPixmap expected = source.copy(TConsole::coverSourceRect(sourceSize, widgetSize)).scaled(widgetSize, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
QCOMPARE(installedBackgroundBrush().toImage(), expected.toImage());
}
void test_unloadableCoverImageIsReportedAndKeepsThePreviousBackground()
{
const QString goodPath = writeImage(qsl("good.png"), QSize(300, 200), Qt::gray);
QVERIFY(!goodPath.isEmpty());
runLua(qsl("setBackgroundImage('main', [[%1]], 'cover', true)").arg(goodPath));
const QImage installed = installedBackgroundBrush().toImage();
QVERIFY(!installed.isNull());
runLua(qsl("bgOk, bgError = setBackgroundImage('main', [[%1]], 'cover', true)").arg(mImageDir.filePath(qsl("no-such-file.png"))));
QVERIFY(luaNil(qsl("bgOk")));
QVERIFY2(luaString(qsl("bgError")).contains(qsl("full window background image")), qPrintable(luaString(qsl("bgError"))));
QCOMPARE(mpHost->mpConsole->mWindowBgImagePath, goodPath);
QCOMPARE(installedBackgroundBrush().toImage(), installed);
}
void test_coverBrushFollowsAWindowResize()
{
const QString imagePath = writeImage(qsl("resize.png"), QSize(3000, 100), Qt::darkRed);
QVERIFY(!imagePath.isEmpty());
runLua(qsl("setBackgroundImage('main', [[%1]], 'cover', true)").arg(imagePath));
const QSize sizeBefore = mpHost->mpConsole->mpWindowBackground->size();
mudlet::self()->resize(900, 640);
QTest::qWait(200ms);
const QSize sizeAfter = mpHost->mpConsole->mpWindowBackground->size();
QVERIFY2(sizeAfter != sizeBefore, "the window did not actually resize");
QCOMPARE(installedBackgroundBrush().size(), sizeAfter);
mudlet::self()->resize(1200, 800);
QTest::qWait(200ms);
}
// clearing a stylesheet repolishes the widget, which can drop the palette brush
void test_switchingFromStylesheetModeToCoverInstallsTheBrush()
{
const QString imagePath = writeImage(qsl("switch.png"), QSize(256, 128), Qt::magenta);
QVERIFY(!imagePath.isEmpty());
runLua(qsl("setBackgroundImage('main', [[%1]], 'border', true)").arg(imagePath));
QVERIFY(!mpHost->mpConsole->mpWindowBackground->styleSheet().isEmpty());
runLua(qsl("setBackgroundImage('main', [[%1]], 'cover', true)").arg(imagePath));
QVERIFY(mpHost->mpConsole->mpWindowBackground->styleSheet().isEmpty());
QCOMPARE(mpHost->mpConsole->mpWindowBackground->palette().brush(QPalette::Window).texture().size(), mpHost->mpConsole->mpWindowBackground->size());
}
};
void initializeQRCResourcesForWindowBackgroundTest()
{
#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 "WindowBackgroundTest.moc"
QTEST_MAIN(WindowBackgroundTest)