mirror of
https://github.com/Mudlet/Mudlet
synced 2026-08-13 18:26:27 -04:00
719 lines
34 KiB
C++
719 lines
34 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 <QClipboard>
|
||
|
|
#include <QFontDatabase>
|
||
|
|
#include <QPainter>
|
||
|
|
#include <QtTest/QtTest>
|
||
|
|
|
||
|
|
#include "Host.h"
|
||
|
|
#include "MudletInstanceCoordinator.h"
|
||
|
|
#include "TLuaInterpreter.h"
|
||
|
|
#include "TMainConsole.h"
|
||
|
|
#include "TTextEdit.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();
|
||
|
|
|
||
|
|
// TTextEdit lays text out in cells of QFontMetrics::height(), which is a
|
||
|
|
// typographic measure rather than the glyph ink box. At a good number of font
|
||
|
|
// sizes the ink of a glyph such as "_" reaches a pixel past the bottom of its
|
||
|
|
// cell, so it only renders completely if nothing paints over that pixel
|
||
|
|
// afterwards. #9070 and #9719 are both reports of underscores vanishing because
|
||
|
|
// something did.
|
||
|
|
class GlyphOverflowTest : public QObject
|
||
|
|
{
|
||
|
|
Q_OBJECT
|
||
|
|
|
||
|
|
private:
|
||
|
|
TelnetServerStub* mpServer = nullptr;
|
||
|
|
const QString mHostname = "Test-GlyphOverflow";
|
||
|
|
QString mPort;
|
||
|
|
const QString mLocalhost = "localhost";
|
||
|
|
|
||
|
|
// What the line below the underscores looks like. Each one takes a different
|
||
|
|
// branch of the background fill in layoutGrapheme().
|
||
|
|
struct Underlay
|
||
|
|
{
|
||
|
|
QString name;
|
||
|
|
QString colourTag;
|
||
|
|
bool selected = false;
|
||
|
|
};
|
||
|
|
|
||
|
|
static QVector<Underlay> underlays()
|
||
|
|
{
|
||
|
|
return {
|
||
|
|
{qsl("the console background"), qsl("<white>")},
|
||
|
|
{qsl("an explicit background colour"), qsl("<white:blue>")},
|
||
|
|
{qsl("a bright background colour"), qsl("<black:yellow>")},
|
||
|
|
{qsl("a selection"), qsl("<blue>"), true},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
static constexpr int kUnderscoreCount = 40;
|
||
|
|
static constexpr int kFillerCount = 60;
|
||
|
|
// Column used to sample what a pixel row looks like where no glyph was
|
||
|
|
// drawn; whatever the line below paints there is the reference for its own
|
||
|
|
// pixel row.
|
||
|
|
static constexpr int kBackgroundSampleColumn = 50;
|
||
|
|
// How far a channel has to move from its row's background before the pixel
|
||
|
|
// counts as glyph ink rather than antialiasing noise.
|
||
|
|
static constexpr int kInkThreshold = 24;
|
||
|
|
// How far past the bottom of a cell to look for ink that overflowed out of it
|
||
|
|
static constexpr int kOverflowScanRows = 4;
|
||
|
|
static const inline QStringList kTestFamilies = {qsl("Bitstream Vera Sans Mono"), qsl("Ubuntu Mono")};
|
||
|
|
static constexpr int kFirstSize = 9;
|
||
|
|
static constexpr int kLastSize = 30;
|
||
|
|
|
||
|
|
static bool pixelIsInk(QRgb pixel, QRgb background)
|
||
|
|
{
|
||
|
|
return qAbs(qRed(pixel) - qRed(background)) > kInkThreshold || qAbs(qGreen(pixel) - qGreen(background)) > kInkThreshold || qAbs(qBlue(pixel) - qBlue(background)) > kInkThreshold;
|
||
|
|
}
|
||
|
|
|
||
|
|
private slots:
|
||
|
|
void initTestCase()
|
||
|
|
{
|
||
|
|
initializeQRCResources();
|
||
|
|
#ifndef INCLUDE_FONTS
|
||
|
|
QSKIP("Built with WITH_FONTS=NO, so the fonts whose metrics this measures are not available");
|
||
|
|
#else
|
||
|
|
// src/main.cpp extracts the bundled fonts into the config directory and
|
||
|
|
// FontManager picks them up from there, but QTEST_MAIN never runs
|
||
|
|
// main(), so on a machine that has not run Mudlet before there is
|
||
|
|
// nothing on disk to pick up and Qt quietly substitutes another family.
|
||
|
|
for (const QString& file : {qsl(":/fonts/ttf-bitstream-vera-1.10/VeraMono.ttf"),
|
||
|
|
qsl(":/fonts/ttf-bitstream-vera-1.10/VeraMoBd.ttf"),
|
||
|
|
qsl(":/fonts/ubuntu-font-family-0.83/UbuntuMono-R.ttf"),
|
||
|
|
qsl(":/fonts/ubuntu-font-family-0.83/UbuntuMono-B.ttf")}) {
|
||
|
|
QVERIFY2(QFontDatabase::addApplicationFont(file) != -1, qPrintable(qsl("Could not register the bundled font %1").arg(file)));
|
||
|
|
}
|
||
|
|
for (const QString& family : kTestFamilies) {
|
||
|
|
QVERIFY2(QFontDatabase::families().contains(family), qPrintable(qsl("'%1' is missing from the font database after registering the bundled files").arg(family)));
|
||
|
|
}
|
||
|
|
#endif
|
||
|
|
}
|
||
|
|
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
|
||
|
|
// A line has to render all of its ink whatever the line below it looks like,
|
||
|
|
// and that ink has to match the same glyph drawn on its own with the same
|
||
|
|
// font, cell geometry and painter flags.
|
||
|
|
void test_lineBelowDoesNotEraseOverflowingInk()
|
||
|
|
{
|
||
|
|
Host* host = startOfflineProfile();
|
||
|
|
QVERIFY2(host, "Could not start an offline profile");
|
||
|
|
TTextEdit* pane = host->mpConsole->mUpperPane;
|
||
|
|
QVERIFY2(pane, "No upper pane available");
|
||
|
|
|
||
|
|
int sizesWithOverflow = 0;
|
||
|
|
for (const QString& family : kTestFamilies) {
|
||
|
|
for (int size = kFirstSize; size <= kLastSize; ++size) {
|
||
|
|
applyFont(host, family, size);
|
||
|
|
QVERIFY2(pane->getColumnCount() > kBackgroundSampleColumn,
|
||
|
|
qPrintable(qsl("%1 %2pt narrowed the pane to %3 columns, too few for the background sample at column %4")
|
||
|
|
.arg(family)
|
||
|
|
.arg(size)
|
||
|
|
.arg(pane->getColumnCount())
|
||
|
|
.arg(kBackgroundSampleColumn)));
|
||
|
|
const int cellHeight = cellHeightOf(pane);
|
||
|
|
const QPair<int, int> expected = referenceInkExtent(pane->font(), qsl("_"), cellWidthOf(pane), cellHeight);
|
||
|
|
if (expected.second >= cellHeight) {
|
||
|
|
++sizesWithOverflow;
|
||
|
|
}
|
||
|
|
|
||
|
|
for (const Underlay& underlay : underlays()) {
|
||
|
|
const QVector<QPoint> ink = renderAndCollectInk(host, underlay);
|
||
|
|
const QString where = qsl("%1 %2pt below %3").arg(family).arg(size).arg(underlay.name);
|
||
|
|
QVERIFY2(!ink.isEmpty(), qPrintable(qsl("%1: no underscore ink rendered at all").arg(where)));
|
||
|
|
|
||
|
|
const QPair<int, int> actual = inkExtent(ink);
|
||
|
|
QVERIFY2(actual == expected,
|
||
|
|
qPrintable(qsl("%1: underscore ink occupies %2 of its cell, expected %3 (cell is %4 tall)").arg(where, describeExtent(actual), describeExtent(expected)).arg(cellHeight)));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (sizesWithOverflow == 0) {
|
||
|
|
// The comparisons above all ran and passed, so this is a coverage
|
||
|
|
// warning rather than a skip
|
||
|
|
QWARN("None of the tested font sizes overflow their cell on this platform, so the overflow case went unexercised");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// The screen is rendered into a pixmap sized from the number of whole
|
||
|
|
// character cells that fit, so the bottom line's overflow only survives if
|
||
|
|
// that pixmap has somewhere to put it.
|
||
|
|
void test_bottomLineOverflowSurvivesThePixmapEdge()
|
||
|
|
{
|
||
|
|
Host* host = startOfflineProfile();
|
||
|
|
QVERIFY2(host, "Could not start an offline profile");
|
||
|
|
TTextEdit* pane = host->mpConsole->mUpperPane;
|
||
|
|
QVERIFY2(pane, "No upper pane available");
|
||
|
|
|
||
|
|
int checkedSizes = 0;
|
||
|
|
for (int size = kFirstSize; size <= kLastSize; ++size) {
|
||
|
|
applyFont(host, kTestFamilies.first(), size);
|
||
|
|
const int cellHeight = cellHeightOf(pane);
|
||
|
|
const int cellWidth = cellWidthOf(pane);
|
||
|
|
const QPair<int, int> expected = referenceInkExtent(pane->font(), qsl("_"), cellWidth, cellHeight);
|
||
|
|
if (expected.second < cellHeight) {
|
||
|
|
continue; // this size keeps its ink inside the cell, nothing to check
|
||
|
|
}
|
||
|
|
// A pane whose height is an exact multiple of the cell height has no
|
||
|
|
// pixel left over for the bottom line's overflow to appear in.
|
||
|
|
if (pane->height() % cellHeight == 0) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
++checkedSizes;
|
||
|
|
|
||
|
|
const int screenHeight = pane->getScreenHeight();
|
||
|
|
runLua(host, qsl("clearWindow()"));
|
||
|
|
runLua(host, qsl("cecho('<white>' .. string.rep('filler\\n', %1) .. '%2\\n')").arg(screenHeight - 1).arg(QString(kUnderscoreCount, QLatin1Char('_'))));
|
||
|
|
pane->forceUpdate();
|
||
|
|
QApplication::processEvents();
|
||
|
|
|
||
|
|
const QImage rendered = renderPane(host);
|
||
|
|
const int cellTop = (screenHeight - 1) * cellHeight;
|
||
|
|
const QPair<int, int> actual = inkExtent(collectInk(rendered, cellTop, cellHeight, cellWidth));
|
||
|
|
QVERIFY2(actual == expected,
|
||
|
|
qPrintable(qsl("%1pt: the bottom line's underscore ink occupies %2 of its cell, expected %3").arg(QString::number(size), describeExtent(actual), describeExtent(expected))));
|
||
|
|
}
|
||
|
|
|
||
|
|
if (checkedSizes == 0) {
|
||
|
|
QSKIP("No font size produced an overflowing bottom line on this platform, so nothing here is being proved");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Repainting part of the pane clears whole character cells, which takes the
|
||
|
|
// previous line's overflow pixel with it. That line sits outside the dirty
|
||
|
|
// region and is not redrawn, so the pixel has to be put back explicitly.
|
||
|
|
void test_partialRepaintKeepsTheLineAboveIntact()
|
||
|
|
{
|
||
|
|
Host* host = startOfflineProfile();
|
||
|
|
QVERIFY2(host, "Could not start an offline profile");
|
||
|
|
TTextEdit* pane = host->mpConsole->mUpperPane;
|
||
|
|
QVERIFY2(pane, "No upper pane available");
|
||
|
|
|
||
|
|
int checkedSizes = 0;
|
||
|
|
for (int size = kFirstSize; size <= kLastSize; ++size) {
|
||
|
|
applyFont(host, kTestFamilies.first(), size);
|
||
|
|
const int cellHeight = cellHeightOf(pane);
|
||
|
|
const int cellWidth = cellWidthOf(pane);
|
||
|
|
const QPair<int, int> expected = referenceInkExtent(pane->font(), qsl("_"), cellWidth, cellHeight);
|
||
|
|
if (expected.second < cellHeight) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
++checkedSizes;
|
||
|
|
|
||
|
|
// More lines than fit, so imageTopLine() is past zero - the
|
||
|
|
// precondition for the partial repaint below to reach
|
||
|
|
// drawForeground()'s cached-pixmap path.
|
||
|
|
runLua(host, qsl("clearWindow()"));
|
||
|
|
runLua(host, qsl("cecho('<white>' .. string.rep('%1\\n', %2))").arg(QString(kUnderscoreCount, QLatin1Char('_'))).arg(pane->getScreenHeight() * 2));
|
||
|
|
pane->forceUpdate();
|
||
|
|
QApplication::processEvents();
|
||
|
|
QVERIFY2(pane->imageTopLine() > 0, "The pane did not scroll, so the partial repaint would not reach the cached-pixmap path");
|
||
|
|
|
||
|
|
// Simulate a partial repaint: the previous frame, the damaged band
|
||
|
|
// reset to the console background, then only that band re-rendered.
|
||
|
|
QImage rendered = renderPane(host);
|
||
|
|
const int row = pane->getScreenHeight() / 2;
|
||
|
|
const QRect damaged(0, row * cellHeight, pane->width(), cellHeight * 2);
|
||
|
|
QPainter eraser(&rendered);
|
||
|
|
eraser.fillRect(damaged, host->mpConsole->getConsoleBgColor());
|
||
|
|
eraser.end();
|
||
|
|
pane->render(&rendered, damaged.topLeft(), QRegion(damaged), QWidget::DrawChildren);
|
||
|
|
|
||
|
|
// Only the bottom of the ink can be pinned here: every line is
|
||
|
|
// underscores, so the top rows of the cell hold the overflow of the
|
||
|
|
// line above it rather than this line's own glyph.
|
||
|
|
const QPair<int, int> actual = inkExtent(collectInk(rendered, (row - 1) * cellHeight, cellHeight, cellWidth));
|
||
|
|
QVERIFY2(actual.second == expected.second,
|
||
|
|
qPrintable(qsl("%1pt: after a partial repaint the line above the dirty region ends at row %2 of its cell, expected %3").arg(size).arg(actual.second).arg(expected.second)));
|
||
|
|
}
|
||
|
|
|
||
|
|
if (checkedSizes == 0) {
|
||
|
|
QSKIP("No font size produced an overflowing line on this platform, so nothing here is being proved");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Scrolling reuses the cached screen by blitting it a whole number of cells
|
||
|
|
// up or down, which lands a complete line of text in the strip below the
|
||
|
|
// last one. Only the bottom line's own overflow belongs there.
|
||
|
|
void test_scrollingLeavesNoGhostLineBelowTheBottomOne()
|
||
|
|
{
|
||
|
|
Host* host = startOfflineProfile();
|
||
|
|
QVERIFY2(host, "Could not start an offline profile");
|
||
|
|
TTextEdit* pane = host->mpConsole->mUpperPane;
|
||
|
|
QVERIFY2(pane, "No upper pane available");
|
||
|
|
|
||
|
|
int checkedSizes = 0;
|
||
|
|
for (int size = kFirstSize; size <= kLastSize; ++size) {
|
||
|
|
applyFont(host, kTestFamilies.first(), size);
|
||
|
|
const int cellHeight = cellHeightOf(pane);
|
||
|
|
const int cellWidth = cellWidthOf(pane);
|
||
|
|
const QPair<int, int> expected = referenceInkExtent(pane->font(), qsl("_"), cellWidth, cellHeight);
|
||
|
|
const int spareTop = pane->getScreenHeight() * cellHeight;
|
||
|
|
if (expected.second < cellHeight || pane->height() - spareTop <= kOverflowScanRows) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
++checkedSizes;
|
||
|
|
|
||
|
|
// Underscores for the overflow, letters past them so that a whole
|
||
|
|
// ghost line would be unmistakable in the strip.
|
||
|
|
const QString line = QString(kUnderscoreCount, QLatin1Char('_')) + QString(20, QLatin1Char('M'));
|
||
|
|
runLua(host, qsl("clearWindow()"));
|
||
|
|
runLua(host, qsl("cecho('<white>' .. string.rep('%1\\n', %2))").arg(line).arg(pane->getScreenHeight() * 4));
|
||
|
|
pane->forceUpdate();
|
||
|
|
QApplication::processEvents();
|
||
|
|
// primes the cached screen the scroll below is blitted from
|
||
|
|
renderPane(host);
|
||
|
|
|
||
|
|
// drawForeground() ignores the cache entirely below ten scrolled-off
|
||
|
|
// lines, so there has to be more scrollback than that
|
||
|
|
const int topLineBeforeScroll = pane->imageTopLine();
|
||
|
|
QVERIFY2(topLineBeforeScroll >= 10, "Not enough scrollback for drawForeground() to take its scrolling path");
|
||
|
|
|
||
|
|
// Render straight after the scroll so the frame under test is the
|
||
|
|
// one drawForeground() builds from the shifted cache.
|
||
|
|
pane->scrollUp(3);
|
||
|
|
QVERIFY2(pane->imageTopLine() < topLineBeforeScroll, "The pane did not scroll back, so the frame below is not built from a shifted cache");
|
||
|
|
const QImage rendered = renderPane(host);
|
||
|
|
|
||
|
|
for (int y = spareTop + kOverflowScanRows; y < pane->height(); ++y) {
|
||
|
|
int litPixels = 0;
|
||
|
|
for (int x = 0; x < rendered.width(); ++x) {
|
||
|
|
if (pixelIsInk(rendered.pixel(x, y), consoleBackground(host))) {
|
||
|
|
++litPixels;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
QVERIFY2(litPixels == 0, qPrintable(qsl("%1pt: %2 stray pixels %3 rows below the last character cell after scrolling back").arg(size).arg(litPixels).arg(y - spareTop)));
|
||
|
|
}
|
||
|
|
|
||
|
|
// As above, the top of the cell holds the previous line's overflow
|
||
|
|
const QPair<int, int> actual = inkExtent(collectInk(rendered, spareTop - cellHeight, cellHeight, cellWidth));
|
||
|
|
QVERIFY2(actual.second == expected.second,
|
||
|
|
qPrintable(qsl("%1pt: after scrolling back the bottom line's underscore ink ends at row %2 of its cell, expected %3").arg(size).arg(actual.second).arg(expected.second)));
|
||
|
|
}
|
||
|
|
|
||
|
|
if (checkedSizes == 0) {
|
||
|
|
QSKIP("No font size produced an overflowing bottom line on this platform, so nothing here is being proved");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Miniconsoles keep the fill rule the main console does not, so check the
|
||
|
|
// paint order protects their overflow too.
|
||
|
|
void test_miniConsoleKeepsOverflowingInk()
|
||
|
|
{
|
||
|
|
Host* host = startOfflineProfile();
|
||
|
|
QVERIFY2(host, "Could not start an offline profile");
|
||
|
|
runLua(host, qsl("createMiniConsole('overflowMini', 0, 0, 800, 400)"));
|
||
|
|
auto* mini = host->mpConsole->mSubConsoleMap.value(qsl("overflowMini"));
|
||
|
|
QVERIFY2(mini, "The miniconsole was not created");
|
||
|
|
TTextEdit* pane = mini->mUpperPane;
|
||
|
|
QVERIFY2(pane, "The miniconsole has no pane");
|
||
|
|
|
||
|
|
int checkedSizes = 0;
|
||
|
|
for (int size = kFirstSize; size <= kLastSize; ++size) {
|
||
|
|
runLua(host, qsl("setFont('overflowMini', '%1')").arg(kTestFamilies.first()));
|
||
|
|
runLua(host, qsl("setMiniConsoleFontSize('overflowMini', %1)").arg(size));
|
||
|
|
QApplication::processEvents();
|
||
|
|
// this one goes through the miniconsole API rather than applyFont(),
|
||
|
|
// so it needs its own check that the family was not substituted
|
||
|
|
QVERIFY2(QFontInfo(pane->font()).family() == kTestFamilies.first(),
|
||
|
|
qPrintable(qsl("The miniconsole resolved to '%1' rather than '%2', so this would measure the wrong glyph").arg(QFontInfo(pane->font()).family(), kTestFamilies.first())));
|
||
|
|
const int cellHeight = cellHeightOf(pane);
|
||
|
|
const int cellWidth = cellWidthOf(pane);
|
||
|
|
const QPair<int, int> expected = referenceInkExtent(pane->font(), qsl("_"), cellWidth, cellHeight);
|
||
|
|
if (expected.second < cellHeight || pane->getColumnCount() <= kBackgroundSampleColumn) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
++checkedSizes;
|
||
|
|
|
||
|
|
runLua(host, qsl("clearWindow('overflowMini')"));
|
||
|
|
runLua(host, qsl("cecho('overflowMini', '<white>%1\\n')").arg(QString(kUnderscoreCount, QLatin1Char('_'))));
|
||
|
|
runLua(host, qsl("cecho('overflowMini', '<white:blue>%1\\n')").arg(QString(kFillerCount, QLatin1Char(' '))));
|
||
|
|
pane->forceUpdate();
|
||
|
|
QApplication::processEvents();
|
||
|
|
|
||
|
|
QImage rendered(pane->size(), QImage::Format_ARGB32_Premultiplied);
|
||
|
|
rendered.fill(mini->getConsoleBgColor());
|
||
|
|
pane->render(&rendered, QPoint(), QRegion(), QWidget::DrawChildren);
|
||
|
|
|
||
|
|
const QPair<int, int> actual = inkExtent(collectInk(rendered, 0, cellHeight, cellWidth));
|
||
|
|
QVERIFY2(actual == expected,
|
||
|
|
qPrintable(qsl("%1pt: a miniconsole's underscore ink occupies %2 of its cell, expected %3").arg(QString::number(size), describeExtent(actual), describeExtent(expected))));
|
||
|
|
}
|
||
|
|
|
||
|
|
if (checkedSizes == 0) {
|
||
|
|
QSKIP("No font size produced an overflowing line in a miniconsole on this platform, so nothing here is being proved");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Copy-as-image sizes its pixmap at exactly one cell per selected line, so
|
||
|
|
// the bottom line's overflow has nowhere to go unless the paint leaves room
|
||
|
|
// for it and the image is trimmed back afterwards.
|
||
|
|
void test_copyAsImageKeepsTheBottomLineOverflow()
|
||
|
|
{
|
||
|
|
Host* host = startOfflineProfile();
|
||
|
|
QVERIFY2(host, "Could not start an offline profile");
|
||
|
|
TTextEdit* pane = host->mpConsole->mUpperPane;
|
||
|
|
QVERIFY2(pane, "No upper pane available");
|
||
|
|
|
||
|
|
int checkedSizes = 0;
|
||
|
|
for (int size = kFirstSize; size <= kLastSize; ++size) {
|
||
|
|
applyFont(host, kTestFamilies.first(), size);
|
||
|
|
const int cellHeight = cellHeightOf(pane);
|
||
|
|
const int cellWidth = cellWidthOf(pane);
|
||
|
|
const QPair<int, int> expected = referenceInkExtent(pane->font(), qsl("_"), cellWidth, cellHeight);
|
||
|
|
if (expected.second < cellHeight) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
++checkedSizes;
|
||
|
|
|
||
|
|
const int selectedLines = 5;
|
||
|
|
runLua(host, qsl("clearWindow()"));
|
||
|
|
runLua(host, qsl("cecho('<white>' .. string.rep('%1\\n', %2))").arg(QString(kUnderscoreCount, QLatin1Char('_'))).arg(selectedLines));
|
||
|
|
pane->forceUpdate();
|
||
|
|
QApplication::processEvents();
|
||
|
|
|
||
|
|
selectRows(pane, 0, selectedLines - 1, cellHeight, cellWidth);
|
||
|
|
QMetaObject::invokeMethod(pane, "slot_copySelectionToClipboardImage", Qt::DirectConnection);
|
||
|
|
QApplication::processEvents();
|
||
|
|
|
||
|
|
const QImage copied = QApplication::clipboard()->image();
|
||
|
|
QVERIFY2(!copied.isNull(), qPrintable(qsl("%1pt: copy as image produced nothing").arg(size)));
|
||
|
|
const QPair<int, int> actual = inkExtentOnFlat(copied, (selectedLines - 1) * cellHeight, cellHeight, consoleBackground(host));
|
||
|
|
QVERIFY2(actual.second == expected.second,
|
||
|
|
qPrintable(qsl("%1pt: the copied image's bottom line ends at row %2 of its cell, expected %3").arg(size).arg(actual.second).arg(expected.second)));
|
||
|
|
// the spare row must not survive as blank padding, nor bring an
|
||
|
|
// extra line of text with it
|
||
|
|
QVERIFY2(copied.height() <= selectedLines * cellHeight + kOverflowScanRows,
|
||
|
|
qPrintable(qsl("%1pt: the copied image is %2px tall for %3 lines of %4px").arg(size).arg(copied.height()).arg(selectedLines).arg(cellHeight)));
|
||
|
|
}
|
||
|
|
|
||
|
|
if (checkedSizes == 0) {
|
||
|
|
QSKIP("No font size produced an overflowing line on this platform, so nothing here is being proved");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
void cleanup()
|
||
|
|
{
|
||
|
|
const QString profilePath = mudlet::getMudletPath(enums::profileHomePath, mHostname);
|
||
|
|
delete mudlet::self();
|
||
|
|
delete mpServer;
|
||
|
|
mpServer = nullptr;
|
||
|
|
deleteDirectory(profilePath);
|
||
|
|
}
|
||
|
|
|
||
|
|
private:
|
||
|
|
// The pane paints its cells onto whatever the parent widget is showing, so
|
||
|
|
// start from the console background rather than letting render() lay down
|
||
|
|
// the default palette colour where no cell was filled.
|
||
|
|
static QImage renderPane(Host* host)
|
||
|
|
{
|
||
|
|
TTextEdit* pane = host->mpConsole->mUpperPane;
|
||
|
|
QImage image(pane->size(), QImage::Format_ARGB32_Premultiplied);
|
||
|
|
image.fill(host->mpConsole->getConsoleBgColor());
|
||
|
|
pane->render(&image, QPoint(), QRegion(), QWidget::DrawChildren);
|
||
|
|
return image;
|
||
|
|
}
|
||
|
|
|
||
|
|
// For images too narrow to carry a background sample column, such as the
|
||
|
|
// copy-as-image output, which is only as wide as the selected text.
|
||
|
|
static QPair<int, int> inkExtentOnFlat(const QImage& image, int cellTop, int cellHeight, QRgb background)
|
||
|
|
{
|
||
|
|
int top = -1;
|
||
|
|
int bottom = -1;
|
||
|
|
for (int y = qMax(0, cellTop); y < qMin(image.height(), cellTop + cellHeight + kOverflowScanRows); ++y) {
|
||
|
|
for (int x = 0; x < image.width(); ++x) {
|
||
|
|
if (pixelIsInk(image.pixel(x, y), background)) {
|
||
|
|
if (top < 0) {
|
||
|
|
top = y;
|
||
|
|
}
|
||
|
|
bottom = y;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return {top - cellTop, bottom - cellTop};
|
||
|
|
}
|
||
|
|
|
||
|
|
static QRgb consoleBackground(Host* host) { return host->mpConsole->getConsoleBgColor().rgb(); }
|
||
|
|
|
||
|
|
static int cellHeightOf(const TTextEdit* pane) { return QFontMetrics(pane->font()).height(); }
|
||
|
|
static int cellWidthOf(const TTextEdit* pane) { return QFontMetrics(pane->font()).averageCharWidth(); }
|
||
|
|
|
||
|
|
// First and last pixel row of a set of ink, as offsets from the cell top.
|
||
|
|
// Empty ink reports {-1, -1} so a completely erased glyph never matches a
|
||
|
|
// real reference extent.
|
||
|
|
static QPair<int, int> inkExtent(const QVector<QPoint>& ink)
|
||
|
|
{
|
||
|
|
if (ink.isEmpty()) {
|
||
|
|
return {-1, -1};
|
||
|
|
}
|
||
|
|
int top = ink.first().y();
|
||
|
|
int bottom = top;
|
||
|
|
for (const QPoint& point : ink) {
|
||
|
|
top = qMin(top, point.y());
|
||
|
|
bottom = qMax(bottom, point.y());
|
||
|
|
}
|
||
|
|
return {top, bottom};
|
||
|
|
}
|
||
|
|
|
||
|
|
static QString describeExtent(const QPair<int, int>& extent) { return qsl("rows %1..%2").arg(extent.first).arg(extent.second); }
|
||
|
|
|
||
|
|
void applyFont(Host* host, const QString& family, int size)
|
||
|
|
{
|
||
|
|
QFont font(family, size);
|
||
|
|
font.setFixedPitch(true);
|
||
|
|
QVERIFY2(QFontInfo(font).family() == family, qPrintable(qsl("Qt substituted '%1' for the requested '%2', so this would measure the wrong glyph").arg(QFontInfo(font).family(), family)));
|
||
|
|
const auto result = host->setDisplayFont(font);
|
||
|
|
QVERIFY2(result.first, qPrintable(qsl("Could not set the display font to %1 %2pt: %3").arg(family).arg(size).arg(result.second)));
|
||
|
|
QApplication::processEvents();
|
||
|
|
}
|
||
|
|
|
||
|
|
// Prints a line of underscores followed by a filler line dressed up as the
|
||
|
|
// given underlay, repaints, and returns the ink of the underscore line.
|
||
|
|
QVector<QPoint> renderAndCollectInk(Host* host, const Underlay& underlay)
|
||
|
|
{
|
||
|
|
TTextEdit* pane = host->mpConsole->mUpperPane;
|
||
|
|
runLua(host, qsl("clearWindow()"));
|
||
|
|
runLua(host, qsl("cecho('<white>%1\\n')").arg(QString(kUnderscoreCount, QLatin1Char('_'))));
|
||
|
|
runLua(host, qsl("cecho('%1%2\\n')").arg(underlay.colourTag, QString(kFillerCount, QLatin1Char(' '))));
|
||
|
|
|
||
|
|
const int underscoreLine = findLine(host, QString(kUnderscoreCount, QLatin1Char('_')));
|
||
|
|
if (underscoreLine < 0) {
|
||
|
|
return {};
|
||
|
|
}
|
||
|
|
if (underlay.selected) {
|
||
|
|
if (underscoreLine + 1 >= static_cast<int>(host->mpConsole->buffer.buffer.size())) {
|
||
|
|
return {};
|
||
|
|
}
|
||
|
|
auto& below = host->mpConsole->buffer.buffer.at(underscoreLine + 1);
|
||
|
|
for (TChar& character : below) {
|
||
|
|
character.select();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
pane->forceUpdate();
|
||
|
|
QApplication::processEvents();
|
||
|
|
|
||
|
|
const QImage rendered = renderPane(host);
|
||
|
|
const int cellHeight = cellHeightOf(pane);
|
||
|
|
return collectInk(rendered, (underscoreLine - pane->imageTopLine()) * cellHeight, cellHeight, cellWidthOf(pane));
|
||
|
|
}
|
||
|
|
|
||
|
|
static int findLine(Host* host, const QString& text)
|
||
|
|
{
|
||
|
|
TBuffer& buffer = host->mpConsole->buffer;
|
||
|
|
for (int i = 0; i <= buffer.getLastLineNumber(); ++i) {
|
||
|
|
if (buffer.line(i) == text) {
|
||
|
|
return i;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return -1;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Every pixel of the underscore run that differs from what its own pixel row
|
||
|
|
// looks like away from the glyphs, as offsets from the cell's top left.
|
||
|
|
// Reaches a few rows past the bottom of the cell so overflow is included.
|
||
|
|
// That only avoids picking up the line below because every caller leaves it
|
||
|
|
// blank or puts underscores on it, whose ink sits at the bottom of a cell.
|
||
|
|
static QVector<QPoint> collectInk(const QImage& image, int cellTop, int cellHeight, int cellWidth)
|
||
|
|
{
|
||
|
|
QVector<QPoint> ink;
|
||
|
|
const int sampleX = kBackgroundSampleColumn * cellWidth + cellWidth / 2;
|
||
|
|
if (sampleX >= image.width() || cellTop < 0) {
|
||
|
|
return ink;
|
||
|
|
}
|
||
|
|
const int lastX = qMin(kUnderscoreCount * cellWidth, image.width()) - 1;
|
||
|
|
const int lastY = qMin(cellTop + cellHeight + kOverflowScanRows, image.height()) - 1;
|
||
|
|
for (int y = cellTop; y <= lastY; ++y) {
|
||
|
|
const QRgb background = image.pixel(sampleX, y);
|
||
|
|
for (int x = 0; x <= lastX; ++x) {
|
||
|
|
if (pixelIsInk(image.pixel(x, y), background)) {
|
||
|
|
ink.append(QPoint(x, y - cellTop));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return ink;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Where the ink of a run of graphemes starts and ends relative to the top of
|
||
|
|
// its cell, drawn cell by cell the way TTextEdit::paintGraphemeForeground()
|
||
|
|
// draws it. The whole run is rendered rather than a single glyph because
|
||
|
|
// neighbouring cells' antialiasing overlaps at the cell boundaries, which
|
||
|
|
// moves the faintest row of the ink.
|
||
|
|
static QPair<int, int> referenceInkExtent(const QFont& font, const QString& grapheme, int cellWidth, int cellHeight)
|
||
|
|
{
|
||
|
|
QImage image(kUnderscoreCount * cellWidth, cellHeight * 3, QImage::Format_ARGB32_Premultiplied);
|
||
|
|
image.fill(Qt::black);
|
||
|
|
QPainter painter(&image);
|
||
|
|
painter.setFont(font);
|
||
|
|
painter.setPen(Qt::white);
|
||
|
|
for (int cell = 0; cell < kUnderscoreCount; ++cell) {
|
||
|
|
painter.drawText(QRect(cell * cellWidth, cellHeight, cellWidth, cellHeight), Qt::AlignCenter | Qt::TextDontClip | Qt::TextSingleLine, grapheme);
|
||
|
|
}
|
||
|
|
painter.end();
|
||
|
|
|
||
|
|
int top = -1;
|
||
|
|
int bottom = -1;
|
||
|
|
for (int y = 0; y < image.height(); ++y) {
|
||
|
|
for (int x = 0; x < image.width(); ++x) {
|
||
|
|
if (pixelIsInk(image.pixel(x, y), qRgb(0, 0, 0))) {
|
||
|
|
if (top < 0) {
|
||
|
|
top = y;
|
||
|
|
}
|
||
|
|
bottom = y;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return {top - cellHeight, bottom - cellHeight};
|
||
|
|
}
|
||
|
|
|
||
|
|
void runLua(Host* host, const QString& script) { QVERIFY2(host->getLuaInterpreter()->compileAndExecuteScript(script), qPrintable(qsl("Lua script failed: %1").arg(script))); }
|
||
|
|
|
||
|
|
Host* startOfflineProfile()
|
||
|
|
{
|
||
|
|
startProfile(mHostname, mLocalhost, mPort);
|
||
|
|
auto* host = mudlet::self()->getActiveHost();
|
||
|
|
if (!host) {
|
||
|
|
return nullptr;
|
||
|
|
}
|
||
|
|
host->mEchoLuaErrors = true;
|
||
|
|
|
||
|
|
mudlet::self()->resize(1400, 900);
|
||
|
|
QApplication::processEvents();
|
||
|
|
|
||
|
|
// cecho() into a live connection would race with the stub's traffic
|
||
|
|
host->mTelnet.disconnectIt();
|
||
|
|
if (!QTest::qWaitFor(
|
||
|
|
[host]() {
|
||
|
|
return host->mTelnet.getConnectionState() == QAbstractSocket::UnconnectedState;
|
||
|
|
},
|
||
|
|
5000)) {
|
||
|
|
qWarning() << "Profile did not go offline in time; stub traffic may interleave with the printed lines";
|
||
|
|
}
|
||
|
|
return host;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Starts a profile the way a user would via the GUI (mirrors the helper in
|
||
|
|
// TelnetTextDisplayedTest).
|
||
|
|
void startProfile(const QString& hostname, const QString& address, const QString& port)
|
||
|
|
{
|
||
|
|
QTimer::singleShot(0, qApp, [hostname, address, port]() {
|
||
|
|
mudlet::self()->startAutoLogin({});
|
||
|
|
QTest::qWait(100);
|
||
|
|
QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton);
|
||
|
|
QTest::qWait(100);
|
||
|
|
QTest::keyClicks(QApplication::focusWidget(), hostname);
|
||
|
|
QTest::qWait(100);
|
||
|
|
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab);
|
||
|
|
QTest::qWait(100);
|
||
|
|
QTest::keyClicks(QApplication::focusWidget(), address);
|
||
|
|
QTest::qWait(100);
|
||
|
|
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab);
|
||
|
|
QTest::qWait(100);
|
||
|
|
QTest::keyClicks(QApplication::focusWidget(), port);
|
||
|
|
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.");
|
||
|
|
}
|
||
|
|
if (!mudlet::self()->getActiveHost()) {
|
||
|
|
QFAIL("No active host available for the test.");
|
||
|
|
}
|
||
|
|
|
||
|
|
QSignalSpy spy2(&(mudlet::self()->getActiveHost()->mTelnet), &cTelnet::signal_connected);
|
||
|
|
if (!spy2.wait(2000)) {
|
||
|
|
QFAIL("Could not connect with the host.");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Drag-selects whole rows, which is the only way in from outside the class.
|
||
|
|
static void selectRows(TTextEdit* pane, int firstRow, int lastRow, int cellHeight, int cellWidth)
|
||
|
|
{
|
||
|
|
auto send = [pane](QEvent::Type type, Qt::MouseButton button, Qt::MouseButtons buttons, const QPointF& pos) {
|
||
|
|
QMouseEvent event(type, pos, pane->mapToGlobal(pos.toPoint()), button, buttons, Qt::NoModifier);
|
||
|
|
QApplication::sendEvent(pane, &event);
|
||
|
|
};
|
||
|
|
const QPointF start(2, firstRow * cellHeight + 2);
|
||
|
|
const QPointF end(kUnderscoreCount * cellWidth - 2, lastRow * cellHeight + cellHeight / 2);
|
||
|
|
send(QEvent::MouseButtonPress, Qt::LeftButton, Qt::LeftButton, start);
|
||
|
|
send(QEvent::MouseMove, Qt::NoButton, Qt::LeftButton, end);
|
||
|
|
send(QEvent::MouseButtonRelease, Qt::LeftButton, Qt::NoButton, end);
|
||
|
|
QApplication::processEvents();
|
||
|
|
}
|
||
|
|
|
||
|
|
void deleteProfileDirectory(const QString& profileName) { deleteDirectory(mudlet::getMudletPath(enums::profileHomePath, profileName)); }
|
||
|
|
|
||
|
|
void deleteDirectory(const QString& path)
|
||
|
|
{
|
||
|
|
QDir dir(path);
|
||
|
|
if (!dir.exists()) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
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 "GlyphOverflowTest.moc"
|
||
|
|
QTEST_MAIN(GlyphOverflowTest)
|