mirror of
https://github.com/NohamR/Reclass.git
synced 2026-05-10 19:59:21 +00:00
- Tree line connectors (Unicode box-drawing ├─ └─ │) at arbitrary depth - Fix editor overwriting tree chars at depth 2+ (applyMarginText Pass 2) - Scanner: unknown value scan, comparison rescan modes (Changed/Unchanged/Increased/Decreased) - New Tailwind theme (tw.json), WCAG contrast fixes for warm/mid themes - Tooltip system (rcxtooltip.h) - Comprehensive README rewrite with full feature inventory - New tests for compose tree lines, scanner, tooltips Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
433 lines
16 KiB
C++
433 lines
16 KiB
C++
#include <QtTest>
|
|
#include <QApplication>
|
|
#include <QPushButton>
|
|
#include <QScreen>
|
|
#include <QImage>
|
|
#include "rcxtooltip.h"
|
|
#include "themes/thememanager.h"
|
|
|
|
using namespace rcx;
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Test suite for the RcxTooltip callout widget
|
|
//
|
|
// These tests verify both geometry math AND real-world behavior:
|
|
// - Actual pixel rendering (catches WA_TranslucentBackground failures)
|
|
// - Leave-event resilience (catches spurious dismiss on tooltip popup)
|
|
// - Dismiss correctness (cursor truly leaves trigger zone)
|
|
// ─────────────────────────────────────────────────────────────────
|
|
class TestTooltip : public QObject {
|
|
Q_OBJECT
|
|
|
|
private:
|
|
QWidget* m_window = nullptr;
|
|
QPushButton* m_btnTop = nullptr;
|
|
QPushButton* m_btnMid = nullptr;
|
|
QPushButton* m_btnLeft = nullptr;
|
|
QPushButton* m_btnRight= nullptr;
|
|
|
|
void showAndProcess(QWidget* trigger, const QString& text) {
|
|
RcxTooltip::instance()->showFor(trigger, text);
|
|
// Process events + allow paint to complete
|
|
QCoreApplication::processEvents();
|
|
QTest::qWait(20);
|
|
QCoreApplication::processEvents();
|
|
}
|
|
|
|
// Count non-transparent pixels in a QImage region
|
|
int countOpaquePixels(const QImage& img, const QRect& region) {
|
|
int count = 0;
|
|
QRect r = region.intersected(img.rect());
|
|
for (int y = r.top(); y <= r.bottom(); ++y)
|
|
for (int x = r.left(); x <= r.right(); ++x)
|
|
if (qAlpha(img.pixel(x, y)) > 0)
|
|
++count;
|
|
return count;
|
|
}
|
|
|
|
private slots:
|
|
void initTestCase() {
|
|
m_window = new QWidget;
|
|
m_window->setFixedSize(800, 600);
|
|
|
|
QScreen* scr = QApplication::primaryScreen();
|
|
QRect avail = scr->availableGeometry();
|
|
m_window->move(avail.center() - QPoint(400, 300));
|
|
|
|
m_btnMid = new QPushButton("Middle", m_window);
|
|
m_btnMid->setFixedSize(80, 24);
|
|
m_btnMid->move(360, 288);
|
|
|
|
m_btnTop = new QPushButton("Top", m_window);
|
|
m_btnTop->setFixedSize(80, 24);
|
|
m_btnTop->move(360, 0);
|
|
|
|
m_btnLeft = new QPushButton("Left", m_window);
|
|
m_btnLeft->setFixedSize(80, 24);
|
|
m_btnLeft->move(0, 288);
|
|
|
|
m_btnRight = new QPushButton("Right", m_window);
|
|
m_btnRight->setFixedSize(80, 24);
|
|
m_btnRight->move(720, 288);
|
|
|
|
m_window->show();
|
|
QVERIFY(QTest::qWaitForWindowExposed(m_window));
|
|
}
|
|
|
|
void cleanupTestCase() {
|
|
RcxTooltip::instance()->dismiss();
|
|
delete m_window;
|
|
m_window = nullptr;
|
|
}
|
|
|
|
void cleanup() {
|
|
RcxTooltip::instance()->dismiss();
|
|
QCoreApplication::processEvents();
|
|
}
|
|
|
|
// ── Singleton ──
|
|
void testSingleton() {
|
|
QCOMPARE(RcxTooltip::instance(), RcxTooltip::instance());
|
|
}
|
|
|
|
// ── Basic show/dismiss ──
|
|
void testShowAndDismiss() {
|
|
auto* tip = RcxTooltip::instance();
|
|
QVERIFY(!tip->isVisible());
|
|
|
|
showAndProcess(m_btnMid, "Hello");
|
|
QVERIFY(tip->isVisible());
|
|
QCOMPARE(tip->currentText(), QString("Hello"));
|
|
QCOMPARE(tip->currentTrigger(), m_btnMid);
|
|
|
|
tip->dismiss();
|
|
QVERIFY(!tip->isVisible());
|
|
QVERIFY(tip->currentTrigger() == nullptr);
|
|
}
|
|
|
|
// ── Empty text / null trigger = dismiss ──
|
|
void testEmptyTextDismisses() {
|
|
auto* tip = RcxTooltip::instance();
|
|
showAndProcess(m_btnMid, "Test");
|
|
QVERIFY(tip->isVisible());
|
|
showAndProcess(m_btnMid, "");
|
|
QVERIFY(!tip->isVisible());
|
|
}
|
|
|
|
void testNullTriggerDismisses() {
|
|
auto* tip = RcxTooltip::instance();
|
|
showAndProcess(m_btnMid, "Test");
|
|
QVERIFY(tip->isVisible());
|
|
showAndProcess(nullptr, "Test");
|
|
QVERIFY(!tip->isVisible());
|
|
}
|
|
|
|
// ── Arrow direction ──
|
|
void testArrowDownByDefault() {
|
|
auto* tip = RcxTooltip::instance();
|
|
showAndProcess(m_btnMid, "Default placement");
|
|
QVERIFY(tip->isVisible());
|
|
QVERIFY(tip->arrowPointsDown());
|
|
|
|
QRect trigGlobal(m_btnMid->mapToGlobal(QPoint(0,0)), m_btnMid->size());
|
|
int tipBottom = tip->y() + tip->height();
|
|
QVERIFY2(tipBottom <= trigGlobal.top() + RcxTooltip::kGap + 2,
|
|
qPrintable(QStringLiteral("tipBottom=%1 trigTop=%2")
|
|
.arg(tipBottom).arg(trigGlobal.top())));
|
|
}
|
|
|
|
void testArrowFlipsAtScreenTop() {
|
|
QScreen* scr = QApplication::primaryScreen();
|
|
QRect avail = scr->availableGeometry();
|
|
QPoint oldPos = m_window->pos();
|
|
m_window->move(avail.center().x() - 400, avail.top());
|
|
QCoreApplication::processEvents();
|
|
|
|
auto* tip = RcxTooltip::instance();
|
|
showAndProcess(m_btnTop, "Flipped");
|
|
QVERIFY(tip->isVisible());
|
|
QVERIFY2(!tip->arrowPointsDown(),
|
|
"Expected arrow to flip upward when trigger is near screen top");
|
|
|
|
QRect trigGlobal(m_btnTop->mapToGlobal(QPoint(0,0)), m_btnTop->size());
|
|
QVERIFY2(tip->y() >= trigGlobal.bottom(),
|
|
qPrintable(QStringLiteral("tipY=%1 trigBottom=%2")
|
|
.arg(tip->y()).arg(trigGlobal.bottom())));
|
|
|
|
m_window->move(oldPos);
|
|
QCoreApplication::processEvents();
|
|
}
|
|
|
|
// ── Arrow centering ──
|
|
void testArrowCenteredOnTrigger() {
|
|
auto* tip = RcxTooltip::instance();
|
|
showAndProcess(m_btnMid, "Center");
|
|
QVERIFY(tip->isVisible());
|
|
|
|
QRect trigGlobal(m_btnMid->mapToGlobal(QPoint(0,0)), m_btnMid->size());
|
|
int trigCenterX = trigGlobal.center().x();
|
|
int arrowGlobalX = tip->x() + tip->arrowLocalX();
|
|
int delta = qAbs(arrowGlobalX - trigCenterX);
|
|
QVERIFY2(delta <= 2,
|
|
qPrintable(QStringLiteral("arrowGlobalX=%1 trigCenterX=%2 delta=%3")
|
|
.arg(arrowGlobalX).arg(trigCenterX).arg(delta)));
|
|
}
|
|
|
|
// ── Anti-teleport ──
|
|
void testNoTeleportSameWidget() {
|
|
auto* tip = RcxTooltip::instance();
|
|
showAndProcess(m_btnMid, "Stable");
|
|
QPoint pos1 = tip->pos();
|
|
showAndProcess(m_btnMid, "Stable");
|
|
QCOMPARE(tip->pos(), pos1);
|
|
}
|
|
|
|
// ── Repositions for different widget ──
|
|
void testRepositionsForDifferentWidget() {
|
|
auto* tip = RcxTooltip::instance();
|
|
showAndProcess(m_btnLeft, "Left");
|
|
QPoint pos1 = tip->pos();
|
|
showAndProcess(m_btnRight, "Right");
|
|
QVERIFY2(tip->pos() != pos1, "Tooltip should move when trigger widget changes");
|
|
}
|
|
|
|
// ── Horizontal clamping ──
|
|
void testHorizontalClampLeft() {
|
|
QScreen* scr = QApplication::primaryScreen();
|
|
QRect avail = scr->availableGeometry();
|
|
QPoint oldPos = m_window->pos();
|
|
m_window->move(avail.left(), avail.center().y() - 300);
|
|
QCoreApplication::processEvents();
|
|
|
|
auto* tip = RcxTooltip::instance();
|
|
showAndProcess(m_btnLeft, "Clamped left");
|
|
QVERIFY(tip->isVisible());
|
|
QVERIFY2(tip->x() >= avail.left(),
|
|
qPrintable(QStringLiteral("tipX=%1 screenLeft=%2")
|
|
.arg(tip->x()).arg(avail.left())));
|
|
|
|
m_window->move(oldPos);
|
|
QCoreApplication::processEvents();
|
|
}
|
|
|
|
void testHorizontalClampRight() {
|
|
QScreen* scr = QApplication::primaryScreen();
|
|
QRect avail = scr->availableGeometry();
|
|
QPoint oldPos = m_window->pos();
|
|
m_window->move(avail.right() - m_window->width(), avail.center().y() - 300);
|
|
QCoreApplication::processEvents();
|
|
|
|
auto* tip = RcxTooltip::instance();
|
|
showAndProcess(m_btnRight, "Clamped right");
|
|
QVERIFY(tip->isVisible());
|
|
QVERIFY2(tip->x() + tip->width() <= avail.right() + 2,
|
|
qPrintable(QStringLiteral("tipRight=%1 screenRight=%2")
|
|
.arg(tip->x() + tip->width()).arg(avail.right())));
|
|
|
|
m_window->move(oldPos);
|
|
QCoreApplication::processEvents();
|
|
}
|
|
|
|
// ── Body rect dimensions ──
|
|
void testBodyRectSanity() {
|
|
auto* tip = RcxTooltip::instance();
|
|
showAndProcess(m_btnMid, "Body");
|
|
QVERIFY(tip->isVisible());
|
|
|
|
QRect body = tip->bodyRect();
|
|
QVERIFY(body.width() > 0);
|
|
QVERIFY(body.height() > 0);
|
|
QCOMPARE(tip->height(), body.height() + RcxTooltip::kArrowH);
|
|
}
|
|
|
|
// ── Constants ──
|
|
void testConstants() {
|
|
QCOMPARE(RcxTooltip::kArrowH, 6);
|
|
QCOMPARE(RcxTooltip::kArrowHalfW, 6);
|
|
QCOMPARE(RcxTooltip::kGap, 2);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────
|
|
// RENDERING VERIFICATION — catches invisible tooltip bugs
|
|
// ──────────────────────────────────────────────────────────────
|
|
|
|
void testShowForRendersBodyPixels() {
|
|
// Show tooltip and grab its rendered pixels.
|
|
// Verify that the body area has non-transparent content.
|
|
auto* tip = RcxTooltip::instance();
|
|
showAndProcess(m_btnMid, "Render test");
|
|
QVERIFY(tip->isVisible());
|
|
|
|
// Force full opacity so grab gets real pixels
|
|
tip->setWindowOpacity(1.0);
|
|
QCoreApplication::processEvents();
|
|
|
|
QImage img = tip->grab().toImage().convertToFormat(QImage::Format_ARGB32);
|
|
QVERIFY2(!img.isNull(), "grab() returned null image");
|
|
QVERIFY2(img.width() > 0 && img.height() > 0, "grab() returned empty image");
|
|
|
|
// Check body rect area for opaque pixels
|
|
QRect body = tip->bodyRect();
|
|
// Inset by 2px to avoid anti-aliased border edges
|
|
QRect checkRect = body.adjusted(2, 2, -2, -2);
|
|
int opaquePixels = countOpaquePixels(img, checkRect);
|
|
int totalPixels = checkRect.width() * checkRect.height();
|
|
|
|
QVERIFY2(opaquePixels > totalPixels / 2,
|
|
qPrintable(QStringLiteral(
|
|
"Body area has too few opaque pixels: %1 / %2 (< 50%%). "
|
|
"The tooltip is not rendering its background.")
|
|
.arg(opaquePixels).arg(totalPixels)));
|
|
}
|
|
|
|
void testArrowRendersPixels() {
|
|
// Verify the triangle arrow region has some opaque pixels.
|
|
auto* tip = RcxTooltip::instance();
|
|
showAndProcess(m_btnMid, "Arrow test");
|
|
QVERIFY(tip->isVisible());
|
|
QVERIFY(tip->arrowPointsDown());
|
|
|
|
tip->setWindowOpacity(1.0);
|
|
QCoreApplication::processEvents();
|
|
|
|
QImage img = tip->grab().toImage().convertToFormat(QImage::Format_ARGB32);
|
|
|
|
// Arrow region: below the body rect, centered on arrowLocalX
|
|
QRect body = tip->bodyRect();
|
|
int arrowTop = body.bottom();
|
|
int arrowLeft = tip->arrowLocalX() - RcxTooltip::kArrowHalfW;
|
|
int arrowRight = tip->arrowLocalX() + RcxTooltip::kArrowHalfW;
|
|
QRect arrowRect(arrowLeft, arrowTop, arrowRight - arrowLeft, RcxTooltip::kArrowH);
|
|
|
|
int opaquePixels = countOpaquePixels(img, arrowRect);
|
|
QVERIFY2(opaquePixels > 0,
|
|
qPrintable(QStringLiteral(
|
|
"Arrow region has 0 opaque pixels — triangle not painted. "
|
|
"arrowRect=(%1,%2 %3x%4) imgSize=(%5x%6)")
|
|
.arg(arrowRect.x()).arg(arrowRect.y())
|
|
.arg(arrowRect.width()).arg(arrowRect.height())
|
|
.arg(img.width()).arg(img.height())));
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────
|
|
// LEAVE EVENT RESILIENCE — catches spurious dismiss bugs
|
|
// ──────────────────────────────────────────────────────────────
|
|
|
|
void testSurvivesLeaveEvent() {
|
|
// The tooltip should NOT be dismissed when a Leave event fires
|
|
// on the trigger widget while the cursor is still in the
|
|
// trigger+tooltip zone (simulates the synthetic Leave that Qt
|
|
// sends when a tooltip window pops up above the trigger).
|
|
auto* tip = RcxTooltip::instance();
|
|
showAndProcess(m_btnMid, "Survive Leave");
|
|
QVERIFY(tip->isVisible());
|
|
|
|
tip->setWindowOpacity(1.0);
|
|
|
|
// Move real cursor to center of trigger (so geometry check passes)
|
|
QPoint trigCenter = m_btnMid->mapToGlobal(
|
|
QPoint(m_btnMid->width() / 2, m_btnMid->height() / 2));
|
|
QCursor::setPos(trigCenter);
|
|
QCoreApplication::processEvents();
|
|
|
|
// Send a Leave event to the trigger (like DarkApp::notify would)
|
|
QEvent leaveEvent(QEvent::Leave);
|
|
QApplication::sendEvent(m_btnMid, &leaveEvent);
|
|
|
|
// Now call scheduleDismiss as DarkApp would
|
|
tip->scheduleDismiss();
|
|
QCoreApplication::processEvents();
|
|
|
|
// Tooltip should STILL be visible — cursor is inside trigger zone
|
|
QVERIFY2(tip->isVisible(),
|
|
"Tooltip was dismissed by spurious Leave event while cursor "
|
|
"was still over the trigger widget");
|
|
|
|
// Wait beyond the dismiss timer to be sure
|
|
QTest::qWait(200);
|
|
QCoreApplication::processEvents();
|
|
QVERIFY2(tip->isVisible(),
|
|
"Tooltip was dismissed after 200ms despite cursor being over trigger");
|
|
}
|
|
|
|
void testDismissesOnRealLeave() {
|
|
// When the cursor truly leaves the trigger+tooltip zone,
|
|
// scheduleDismiss() should queue dismissal and it should fire.
|
|
auto* tip = RcxTooltip::instance();
|
|
showAndProcess(m_btnMid, "Real leave");
|
|
QVERIFY(tip->isVisible());
|
|
|
|
tip->setWindowOpacity(1.0);
|
|
|
|
// Move cursor far away from both trigger and tooltip
|
|
QScreen* scr = QApplication::primaryScreen();
|
|
QRect avail = scr->availableGeometry();
|
|
QCursor::setPos(avail.bottomRight() - QPoint(10, 10));
|
|
QCoreApplication::processEvents();
|
|
|
|
// scheduleDismiss should detect cursor is outside zone
|
|
tip->scheduleDismiss();
|
|
QCoreApplication::processEvents();
|
|
|
|
// Wait for the 100ms dismiss timer
|
|
QTest::qWait(200);
|
|
QCoreApplication::processEvents();
|
|
|
|
QVERIFY2(!tip->isVisible(),
|
|
"Tooltip should have been dismissed when cursor left the zone");
|
|
}
|
|
|
|
void testLeaveAndReshow() {
|
|
// Dismiss via real leave, then re-show on a different widget.
|
|
auto* tip = RcxTooltip::instance();
|
|
showAndProcess(m_btnMid, "First");
|
|
QVERIFY(tip->isVisible());
|
|
|
|
// Force dismiss
|
|
tip->dismiss();
|
|
QCoreApplication::processEvents();
|
|
QVERIFY(!tip->isVisible());
|
|
|
|
// Re-show on different widget
|
|
showAndProcess(m_btnLeft, "Second");
|
|
QVERIFY2(tip->isVisible(), "Tooltip failed to re-appear after dismiss");
|
|
QCOMPARE(tip->currentText(), QString("Second"));
|
|
QCOMPARE(tip->currentTrigger(), m_btnLeft);
|
|
}
|
|
|
|
// ── Scheduled dismiss cancelled by new showFor ──
|
|
void testScheduledDismissCancelledByShow() {
|
|
auto* tip = RcxTooltip::instance();
|
|
showAndProcess(m_btnMid, "First");
|
|
|
|
// Move cursor far away and schedule dismiss
|
|
QScreen* scr = QApplication::primaryScreen();
|
|
QCursor::setPos(scr->availableGeometry().bottomRight() - QPoint(10, 10));
|
|
QCoreApplication::processEvents();
|
|
tip->scheduleDismiss();
|
|
|
|
// Before timer fires, show on a different widget
|
|
showAndProcess(m_btnLeft, "Second");
|
|
QTest::qWait(200);
|
|
QCoreApplication::processEvents();
|
|
|
|
// Should still be visible — new showFor cancelled the timer
|
|
QVERIFY(tip->isVisible());
|
|
QCOMPARE(tip->currentText(), QString("Second"));
|
|
}
|
|
|
|
// ── Text change on same widget ──
|
|
void testTextChangeOnSameWidget() {
|
|
auto* tip = RcxTooltip::instance();
|
|
showAndProcess(m_btnMid, "Text A");
|
|
QCOMPARE(tip->currentText(), QString("Text A"));
|
|
|
|
tip->dismiss();
|
|
showAndProcess(m_btnMid, "Text B");
|
|
QCOMPARE(tip->currentText(), QString("Text B"));
|
|
}
|
|
};
|
|
|
|
QTEST_MAIN(TestTooltip)
|
|
#include "test_tooltip.moc"
|