mirror of
https://github.com/NohamR/Reclass.git
synced 2026-05-10 19:59:21 +00:00
feat: tree lines, scanner improvements, themes, tooltips, README overhaul
- 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>
This commit is contained in:
@@ -2627,6 +2627,122 @@ private slots:
|
||||
QVERIFY2(result.text.contains(QStringLiteral("\u2192")),
|
||||
qPrintable("Expected arrow (\u2192) in text:\n" + result.text));
|
||||
}
|
||||
void testTreeLinesDepth2() {
|
||||
// Diagnostic test: verify tree chars at depth 2+ with hex64 nodes
|
||||
// (matches user's actual scenario — Hex64 inside pointer expansion)
|
||||
NodeTree tree;
|
||||
tree.baseAddress = 0;
|
||||
|
||||
// Root struct "Unnamed"
|
||||
Node root;
|
||||
root.kind = NodeKind::Struct;
|
||||
root.name = "Unnamed";
|
||||
root.parentId = 0;
|
||||
root.offset = 0;
|
||||
int ri = tree.addNode(root);
|
||||
uint64_t rootId = tree.nodes[ri].id;
|
||||
|
||||
// First child: hex64 at depth 1
|
||||
Node f1;
|
||||
f1.kind = NodeKind::Hex64;
|
||||
f1.name = "";
|
||||
f1.parentId = rootId;
|
||||
f1.offset = 0;
|
||||
tree.addNode(f1);
|
||||
|
||||
// Ref struct "NewClass" (separate root-level definition)
|
||||
Node inner;
|
||||
inner.kind = NodeKind::Struct;
|
||||
inner.name = "NewClass";
|
||||
inner.parentId = 0;
|
||||
inner.offset = 200;
|
||||
int ii = tree.addNode(inner);
|
||||
uint64_t innerId = tree.nodes[ii].id;
|
||||
|
||||
// hex64 children of NewClass
|
||||
Node if1;
|
||||
if1.kind = NodeKind::Hex64;
|
||||
if1.name = "";
|
||||
if1.parentId = innerId;
|
||||
if1.offset = 0;
|
||||
tree.addNode(if1);
|
||||
|
||||
Node if2;
|
||||
if2.kind = NodeKind::Hex64;
|
||||
if2.name = "";
|
||||
if2.parentId = innerId;
|
||||
if2.offset = 8;
|
||||
tree.addNode(if2);
|
||||
|
||||
Node if3;
|
||||
if3.kind = NodeKind::Hex64;
|
||||
if3.name = "";
|
||||
if3.parentId = innerId;
|
||||
if3.offset = 16;
|
||||
tree.addNode(if3);
|
||||
|
||||
// Pointer in root referencing NewClass
|
||||
Node ptr;
|
||||
ptr.kind = NodeKind::Pointer64;
|
||||
ptr.name = "field_0008";
|
||||
ptr.parentId = rootId;
|
||||
ptr.offset = 8;
|
||||
ptr.refId = innerId;
|
||||
tree.addNode(ptr);
|
||||
|
||||
// Last child: hex64 at depth 1
|
||||
Node f2;
|
||||
f2.kind = NodeKind::Hex64;
|
||||
f2.name = "";
|
||||
f2.parentId = rootId;
|
||||
f2.offset = 16;
|
||||
tree.addNode(f2);
|
||||
|
||||
// Provider with pointer value
|
||||
QByteArray data(256, '\0');
|
||||
uint64_t ptrVal = 100;
|
||||
memcpy(data.data() + 8, &ptrVal, 8);
|
||||
BufferProvider prov(data);
|
||||
|
||||
// Compose WITH tree lines
|
||||
ComposeResult result = compose(tree, prov, 0, false, true);
|
||||
|
||||
QStringList lines = result.text.split('\n');
|
||||
|
||||
// Print output with char codes for debugging
|
||||
qDebug() << "=== Tree lines compose output (hex64 scenario) ===";
|
||||
for (int i = 0; i < lines.size(); i++) {
|
||||
// Also show hex of first 15 chars to see tree chars
|
||||
QString hexChars;
|
||||
for (int c = 0; c < qMin(15, lines[i].size()); c++)
|
||||
hexChars += QString("U+%1 ").arg(lines[i][c].unicode(), 4, 16, QChar('0'));
|
||||
qDebug().noquote() << QString("[%1] d=%2 k=%3: %4")
|
||||
.arg(i, 2).arg(result.meta[i].depth).arg((int)result.meta[i].lineKind).arg(lines[i]);
|
||||
qDebug().noquote() << QString(" hex: %1").arg(hexChars);
|
||||
}
|
||||
qDebug() << "=== end ===";
|
||||
|
||||
// Verify depth-2 lines contain tree chars
|
||||
QChar vertLine(0x2502); // │
|
||||
QChar tee(0x251C); // ├
|
||||
QChar corner(0x2514); // └
|
||||
|
||||
bool foundDepth2TreeChar = false;
|
||||
for (int i = 0; i < result.meta.size(); i++) {
|
||||
if (result.meta[i].depth == 2
|
||||
&& result.meta[i].lineKind != LineKind::Footer) {
|
||||
bool has = lines[i].contains(vertLine)
|
||||
|| lines[i].contains(tee)
|
||||
|| lines[i].contains(corner);
|
||||
if (has) foundDepth2TreeChar = true;
|
||||
QVERIFY2(has,
|
||||
qPrintable(QString("Depth-2 line %1 missing tree chars: %2")
|
||||
.arg(i).arg(lines[i])));
|
||||
}
|
||||
}
|
||||
QVERIFY2(foundDepth2TreeChar,
|
||||
qPrintable("No depth-2 lines with tree chars found:\n" + result.text));
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_MAIN(TestCompose)
|
||||
|
||||
112
tests/test_dbgdump.cpp
Normal file
112
tests/test_dbgdump.cpp
Normal file
@@ -0,0 +1,112 @@
|
||||
#include <cstdio>
|
||||
#include <cstdint>
|
||||
#include <windows.h>
|
||||
#include <initguid.h>
|
||||
#include <dbgeng.h>
|
||||
|
||||
int main(int argc, char* argv[])
|
||||
{
|
||||
const char* dumpPath = "F:\\MEMORY_EaService2024.DMP";
|
||||
if (argc > 1) dumpPath = argv[1];
|
||||
|
||||
HRESULT hrCom = CoInitializeEx(NULL, COINIT_MULTITHREADED);
|
||||
printf("CoInitializeEx: 0x%08lX\n", hrCom);
|
||||
fflush(stdout);
|
||||
|
||||
IDebugClient* client = nullptr;
|
||||
HRESULT hr = DebugCreate(IID_IDebugClient, (void**)&client);
|
||||
printf("DebugCreate: 0x%08lX, client=%p\n", hr, (void*)client);
|
||||
fflush(stdout);
|
||||
|
||||
if (FAILED(hr) || !client) {
|
||||
printf("FAILED to create debug client\n");
|
||||
if (SUCCEEDED(hrCom)) CoUninitialize();
|
||||
return 1;
|
||||
}
|
||||
|
||||
printf("Opening dump: %s\n", dumpPath);
|
||||
fflush(stdout);
|
||||
hr = client->OpenDumpFile(dumpPath);
|
||||
printf("OpenDumpFile: 0x%08lX\n", hr);
|
||||
fflush(stdout);
|
||||
|
||||
if (FAILED(hr)) {
|
||||
printf("FAILED to open dump\n");
|
||||
client->Release();
|
||||
if (SUCCEEDED(hrCom)) CoUninitialize();
|
||||
return 1;
|
||||
}
|
||||
|
||||
IDebugControl* ctrl = nullptr;
|
||||
client->QueryInterface(IID_IDebugControl, (void**)&ctrl);
|
||||
|
||||
if (ctrl) {
|
||||
printf("WaitForEvent(10s)...\n");
|
||||
fflush(stdout);
|
||||
hr = ctrl->WaitForEvent(0, 10000);
|
||||
printf("WaitForEvent: 0x%08lX\n", hr);
|
||||
fflush(stdout);
|
||||
|
||||
ULONG debugClass = 0, debugQual = 0;
|
||||
hr = ctrl->GetDebuggeeType(&debugClass, &debugQual);
|
||||
printf("GetDebuggeeType: 0x%08lX, class=%lu, qualifier=%lu\n",
|
||||
hr, debugClass, debugQual);
|
||||
printf(" -> %s\n", debugQual >= 1024 ? "DUMP" : "LIVE");
|
||||
fflush(stdout);
|
||||
}
|
||||
|
||||
IDebugDataSpaces* ds = nullptr;
|
||||
client->QueryInterface(IID_IDebugDataSpaces, (void**)&ds);
|
||||
|
||||
IDebugSymbols* sym = nullptr;
|
||||
client->QueryInterface(IID_IDebugSymbols, (void**)&sym);
|
||||
|
||||
if (sym) {
|
||||
ULONG numMods = 0, numUnloaded = 0;
|
||||
hr = sym->GetNumberModules(&numMods, &numUnloaded);
|
||||
printf("GetNumberModules: 0x%08lX, loaded=%lu, unloaded=%lu\n",
|
||||
hr, numMods, numUnloaded);
|
||||
fflush(stdout);
|
||||
|
||||
if (numMods > 0) {
|
||||
ULONG64 base = 0;
|
||||
hr = sym->GetModuleByIndex(0, &base);
|
||||
printf("Module[0] base: 0x%llX (hr=0x%08lX)\n", base, hr);
|
||||
fflush(stdout);
|
||||
|
||||
if (SUCCEEDED(hr) && base && ds) {
|
||||
uint8_t buf[16] = {};
|
||||
ULONG got = 0;
|
||||
hr = ds->ReadVirtual(base, buf, 16, &got);
|
||||
printf("ReadVirtual(0x%llX, 16): hr=0x%08lX, got=%lu\n", base, hr, got);
|
||||
printf(" data: ");
|
||||
for (int i = 0; i < 16; i++) printf("%02X ", buf[i]);
|
||||
printf("\n");
|
||||
fflush(stdout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try reading kernel base directly
|
||||
uint64_t ntBase = 0xfffff80123c00000ULL;
|
||||
if (ds) {
|
||||
uint8_t buf[16] = {};
|
||||
ULONG got = 0;
|
||||
hr = ds->ReadVirtual(ntBase, buf, 16, &got);
|
||||
printf("ReadVirtual(nt base 0x%llX, 16): hr=0x%08lX, got=%lu\n", ntBase, hr, got);
|
||||
printf(" data: ");
|
||||
for (int i = 0; i < 16; i++) printf("%02X ", buf[i]);
|
||||
printf("\n");
|
||||
fflush(stdout);
|
||||
}
|
||||
|
||||
if (sym) sym->Release();
|
||||
if (ds) ds->Release();
|
||||
if (ctrl) ctrl->Release();
|
||||
client->DetachProcesses();
|
||||
client->Release();
|
||||
|
||||
printf("Done.\n");
|
||||
if (SUCCEEDED(hrCom)) CoUninitialize();
|
||||
return 0;
|
||||
}
|
||||
@@ -1072,6 +1072,120 @@ private slots:
|
||||
auto results = finSpy.first().first().value<QVector<ScanResult>>();
|
||||
QCOMPARE(results.size(), 7); // 8 - 2 + 1 = 7 positions
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Address range filtering — "Current Struct" support
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
void scan_addressRangeNoLimit() {
|
||||
// startAddress=0, endAddress=0 → scan all (default behavior unchanged)
|
||||
QByteArray data(32, '\x00');
|
||||
data[8] = '\xAA'; data[16] = '\xAA'; data[24] = '\xAA';
|
||||
auto prov = std::make_shared<BufferProvider>(data);
|
||||
ScanEngine engine;
|
||||
QSignalSpy finSpy(&engine, &ScanEngine::finished);
|
||||
|
||||
ScanRequest req;
|
||||
req.pattern = QByteArray("\xAA", 1);
|
||||
req.mask = QByteArray("\xFF", 1);
|
||||
|
||||
engine.start(prov, req);
|
||||
QVERIFY(finSpy.wait(5000));
|
||||
auto results = finSpy.first().first().value<QVector<ScanResult>>();
|
||||
QCOMPARE(results.size(), 3); // all 3 found
|
||||
}
|
||||
|
||||
void scan_addressRangeClipsResults() {
|
||||
// Only scan addresses [8, 20) — should find match at offset 8 and 16 but not 24
|
||||
QByteArray data(32, '\x00');
|
||||
data[8] = '\xAA'; data[16] = '\xAA'; data[24] = '\xAA';
|
||||
auto prov = std::make_shared<BufferProvider>(data);
|
||||
ScanEngine engine;
|
||||
QSignalSpy finSpy(&engine, &ScanEngine::finished);
|
||||
|
||||
ScanRequest req;
|
||||
req.pattern = QByteArray("\xAA", 1);
|
||||
req.mask = QByteArray("\xFF", 1);
|
||||
req.startAddress = 8;
|
||||
req.endAddress = 20;
|
||||
|
||||
engine.start(prov, req);
|
||||
QVERIFY(finSpy.wait(5000));
|
||||
auto results = finSpy.first().first().value<QVector<ScanResult>>();
|
||||
QCOMPARE(results.size(), 2);
|
||||
QCOMPARE(results[0].address, (uint64_t)8);
|
||||
QCOMPARE(results[1].address, (uint64_t)16);
|
||||
}
|
||||
|
||||
void scan_addressRangeOutsideData() {
|
||||
// Range entirely outside data → no results
|
||||
QByteArray data(16, '\xAA');
|
||||
auto prov = std::make_shared<BufferProvider>(data);
|
||||
ScanEngine engine;
|
||||
QSignalSpy finSpy(&engine, &ScanEngine::finished);
|
||||
|
||||
ScanRequest req;
|
||||
req.pattern = QByteArray("\xAA", 1);
|
||||
req.mask = QByteArray("\xFF", 1);
|
||||
req.startAddress = 100;
|
||||
req.endAddress = 200;
|
||||
|
||||
engine.start(prov, req);
|
||||
QVERIFY(finSpy.wait(5000));
|
||||
auto results = finSpy.first().first().value<QVector<ScanResult>>();
|
||||
QCOMPARE(results.size(), 0);
|
||||
}
|
||||
|
||||
void scan_addressRangeWithRegions() {
|
||||
// Two regions: [1000, 1016) and [2000, 2016). Range [1000, 1020) clips to first region only.
|
||||
QByteArray data(4096, '\x00');
|
||||
// Place \xBB at offset 1000 and 2000
|
||||
data[1000] = '\xBB';
|
||||
data[2000] = '\xBB';
|
||||
|
||||
QVector<MemoryRegion> regions;
|
||||
{ MemoryRegion r; r.base = 1000; r.size = 16; r.readable = true; r.writable = true; regions.append(r); }
|
||||
{ MemoryRegion r; r.base = 2000; r.size = 16; r.readable = true; r.writable = true; regions.append(r); }
|
||||
|
||||
auto prov = std::make_shared<RegionProvider>(data, regions);
|
||||
ScanEngine engine;
|
||||
QSignalSpy finSpy(&engine, &ScanEngine::finished);
|
||||
|
||||
ScanRequest req;
|
||||
req.pattern = QByteArray("\xBB", 1);
|
||||
req.mask = QByteArray("\xFF", 1);
|
||||
req.startAddress = 1000;
|
||||
req.endAddress = 1020;
|
||||
|
||||
engine.start(prov, req);
|
||||
QVERIFY(finSpy.wait(5000));
|
||||
auto results = finSpy.first().first().value<QVector<ScanResult>>();
|
||||
QCOMPARE(results.size(), 1);
|
||||
QCOMPARE(results[0].address, (uint64_t)1000);
|
||||
}
|
||||
|
||||
void scan_unknownWithAddressRange() {
|
||||
// Unknown scan with address range should only capture within range
|
||||
QByteArray data(32, '\x42');
|
||||
auto prov = std::make_shared<BufferProvider>(data);
|
||||
ScanEngine engine;
|
||||
QSignalSpy finSpy(&engine, &ScanEngine::finished);
|
||||
|
||||
ScanRequest req;
|
||||
req.condition = ScanCondition::UnknownValue;
|
||||
req.valueSize = 4;
|
||||
req.alignment = 4;
|
||||
req.startAddress = 8;
|
||||
req.endAddress = 24;
|
||||
|
||||
engine.start(prov, req);
|
||||
QVERIFY(finSpy.wait(5000));
|
||||
auto results = finSpy.first().first().value<QVector<ScanResult>>();
|
||||
// Range [8, 24) = 16 bytes, alignment 4, valueSize 4 → offsets 8, 12, 16, 20 = 4 results
|
||||
QCOMPARE(results.size(), 4);
|
||||
QCOMPARE(results[0].address, (uint64_t)8);
|
||||
QCOMPARE(results[3].address, (uint64_t)20);
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_MAIN(TestScanner)
|
||||
|
||||
@@ -1103,6 +1103,89 @@ private slots:
|
||||
// Provider getter is lazy (captures at scan time)
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// "Current Struct" checkbox
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
void structOnly_checkboxExists() {
|
||||
QVERIFY(m_panel->structOnlyCheck() != nullptr);
|
||||
QCOMPARE(m_panel->structOnlyCheck()->isChecked(), false);
|
||||
QCOMPARE(m_panel->structOnlyCheck()->text(), QStringLiteral("Current Struct"));
|
||||
}
|
||||
|
||||
void structOnly_setsAddressRange() {
|
||||
// Set up a bounds getter that returns a known range
|
||||
m_panel->setBoundsGetter([]() -> ScannerPanel::StructBounds {
|
||||
return { 0x1000, 0x200 };
|
||||
});
|
||||
|
||||
// Set up a simple buffer provider
|
||||
QByteArray data(0x2000, '\x00');
|
||||
data[0x1000] = '\xCC';
|
||||
data[0x1100] = '\xCC';
|
||||
data[0x1500] = '\xCC'; // outside bounds (0x1000 + 0x200 = 0x1200)
|
||||
auto prov = std::make_shared<BufferProvider>(data);
|
||||
m_panel->setProviderGetter([prov]() { return prov; });
|
||||
|
||||
// Enable struct-only mode
|
||||
m_panel->structOnlyCheck()->setChecked(true);
|
||||
|
||||
// Scan for \xCC
|
||||
m_panel->patternEdit()->setText("CC");
|
||||
QSignalSpy finSpy(m_panel->engine(), &ScanEngine::finished);
|
||||
QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton);
|
||||
QVERIFY(finSpy.wait(5000));
|
||||
QApplication::processEvents();
|
||||
|
||||
// Should only find results within [0x1000, 0x1200)
|
||||
auto results = finSpy.first().first().value<QVector<ScanResult>>();
|
||||
QCOMPARE(results.size(), 2);
|
||||
}
|
||||
|
||||
void structOnly_uncheckedScansAll() {
|
||||
// Same setup but with checkbox unchecked — should find all 3
|
||||
m_panel->setBoundsGetter([]() -> ScannerPanel::StructBounds {
|
||||
return { 0x1000, 0x200 };
|
||||
});
|
||||
|
||||
QByteArray data(0x2000, '\x00');
|
||||
data[0x1000] = '\xCC';
|
||||
data[0x1100] = '\xCC';
|
||||
data[0x1500] = '\xCC';
|
||||
auto prov = std::make_shared<BufferProvider>(data);
|
||||
m_panel->setProviderGetter([prov]() { return prov; });
|
||||
|
||||
m_panel->structOnlyCheck()->setChecked(false); // unchecked
|
||||
|
||||
m_panel->patternEdit()->setText("CC");
|
||||
QSignalSpy finSpy(m_panel->engine(), &ScanEngine::finished);
|
||||
QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton);
|
||||
QVERIFY(finSpy.wait(5000));
|
||||
QApplication::processEvents();
|
||||
|
||||
auto results = finSpy.first().first().value<QVector<ScanResult>>();
|
||||
QCOMPARE(results.size(), 3);
|
||||
}
|
||||
|
||||
void structOnly_noBoundsGetterIgnored() {
|
||||
// No bounds getter set — checkbox checked but no effect
|
||||
QByteArray data(16, '\xDD');
|
||||
auto prov = std::make_shared<BufferProvider>(data);
|
||||
m_panel->setProviderGetter([prov]() { return prov; });
|
||||
|
||||
m_panel->structOnlyCheck()->setChecked(true);
|
||||
// Don't set bounds getter
|
||||
|
||||
m_panel->patternEdit()->setText("DD");
|
||||
QSignalSpy finSpy(m_panel->engine(), &ScanEngine::finished);
|
||||
QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton);
|
||||
QVERIFY(finSpy.wait(5000));
|
||||
QApplication::processEvents();
|
||||
|
||||
auto results = finSpy.first().first().value<QVector<ScanResult>>();
|
||||
QCOMPARE(results.size(), 16); // all 16 bytes match
|
||||
}
|
||||
|
||||
void providerGetter_lazy() {
|
||||
auto prov1 = std::make_shared<BufferProvider>(QByteArray(16, '\xAA'));
|
||||
auto prov2 = std::make_shared<BufferProvider>(QByteArray(16, '\xBB'));
|
||||
|
||||
432
tests/test_tooltip.cpp
Normal file
432
tests/test_tooltip.cpp
Normal file
@@ -0,0 +1,432 @@
|
||||
#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"
|
||||
292
tests/test_tooltip_event.cpp
Normal file
292
tests/test_tooltip_event.cpp
Normal file
@@ -0,0 +1,292 @@
|
||||
// Tests the full tooltip flow including DarkApp-style ToolTip interception.
|
||||
// Verifies that QEvent::ToolTip fires and our custom tooltip appears.
|
||||
|
||||
#include <QtTest>
|
||||
#include <QApplication>
|
||||
#include <QPushButton>
|
||||
#include <QScreen>
|
||||
#include <QHelpEvent>
|
||||
#include <QImage>
|
||||
#include "rcxtooltip.h"
|
||||
#include "themes/thememanager.h"
|
||||
#include <cstdio>
|
||||
|
||||
using namespace rcx;
|
||||
|
||||
static void LOG(const char* fmt, ...) {
|
||||
va_list ap;
|
||||
va_start(ap, fmt);
|
||||
vfprintf(stdout, fmt, ap);
|
||||
va_end(ap);
|
||||
fflush(stdout);
|
||||
}
|
||||
|
||||
// Simulates DarkApp::notify behavior — installed as a global event filter
|
||||
class DarkAppSimulator : public QObject {
|
||||
public:
|
||||
int tooltipEventCount = 0;
|
||||
int leaveEventCount = 0;
|
||||
int showForCallCount = 0;
|
||||
|
||||
bool eventFilter(QObject* obj, QEvent* ev) override {
|
||||
if (ev->type() == QEvent::ToolTip) {
|
||||
tooltipEventCount++;
|
||||
if (obj->isWidgetType()) {
|
||||
auto* w = static_cast<QWidget*>(obj);
|
||||
QString tip = w->toolTip();
|
||||
LOG(" [darkapp-sim] ToolTip #%d on '%s' tip='%s'\n",
|
||||
tooltipEventCount, qPrintable(w->objectName()),
|
||||
qPrintable(tip.left(60)));
|
||||
if (!tip.isEmpty()) {
|
||||
showForCallCount++;
|
||||
LOG(" [darkapp-sim] calling showFor #%d\n", showForCallCount);
|
||||
RcxTooltip::instance()->showFor(w, tip);
|
||||
LOG(" [darkapp-sim] after showFor: visible=%d pos=(%d,%d) size=%dx%d\n",
|
||||
RcxTooltip::instance()->isVisible(),
|
||||
RcxTooltip::instance()->x(), RcxTooltip::instance()->y(),
|
||||
RcxTooltip::instance()->width(), RcxTooltip::instance()->height());
|
||||
return true; // consume — same as DarkApp
|
||||
}
|
||||
}
|
||||
return true; // suppress default QToolTip
|
||||
}
|
||||
if (ev->type() == QEvent::Leave && obj->isWidgetType()) {
|
||||
auto* tip = RcxTooltip::instance();
|
||||
if (tip->isVisible() && tip->currentTrigger() == obj) {
|
||||
leaveEventCount++;
|
||||
LOG(" [darkapp-sim] Leave #%d on trigger\n", leaveEventCount);
|
||||
tip->scheduleDismiss();
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
class TestTooltipEvent : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
private:
|
||||
QWidget* m_window = nullptr;
|
||||
QPushButton* m_btn = nullptr;
|
||||
QPushButton* m_btn2 = nullptr;
|
||||
DarkAppSimulator* m_sim = nullptr;
|
||||
|
||||
private slots:
|
||||
void initTestCase() {
|
||||
LOG("=== TestTooltipEvent starting ===\n");
|
||||
|
||||
m_window = new QWidget;
|
||||
m_window->setFixedSize(400, 300);
|
||||
QScreen* scr = QApplication::primaryScreen();
|
||||
QRect avail = scr->availableGeometry();
|
||||
m_window->move(avail.center() - QPoint(200, 150));
|
||||
|
||||
m_btn = new QPushButton("Scan", m_window);
|
||||
m_btn->setToolTip("Start scanning memory");
|
||||
m_btn->setFixedSize(120, 40);
|
||||
m_btn->move(30, 130);
|
||||
m_btn->setObjectName("btnScan");
|
||||
|
||||
m_btn2 = new QPushButton("Copy", m_window);
|
||||
m_btn2->setToolTip("Copy to clipboard");
|
||||
m_btn2->setFixedSize(120, 40);
|
||||
m_btn2->move(250, 130);
|
||||
m_btn2->setObjectName("btnCopy");
|
||||
|
||||
// Install DarkApp simulator as global event filter
|
||||
m_sim = new DarkAppSimulator;
|
||||
qApp->installEventFilter(m_sim);
|
||||
|
||||
m_window->show();
|
||||
m_window->activateWindow();
|
||||
m_window->raise();
|
||||
QVERIFY(QTest::qWaitForWindowExposed(m_window));
|
||||
// Let window become active
|
||||
QTest::qWait(200);
|
||||
QCoreApplication::processEvents();
|
||||
|
||||
LOG(" window at (%d,%d)\n", m_window->x(), m_window->y());
|
||||
LOG(" btn global: (%d,%d)\n",
|
||||
m_btn->mapToGlobal(QPoint(60, 20)).x(),
|
||||
m_btn->mapToGlobal(QPoint(60, 20)).y());
|
||||
}
|
||||
|
||||
void cleanupTestCase() {
|
||||
qApp->removeEventFilter(m_sim);
|
||||
RcxTooltip::instance()->dismiss();
|
||||
delete m_sim;
|
||||
delete m_window;
|
||||
LOG("=== TestTooltipEvent finished ===\n");
|
||||
}
|
||||
|
||||
void cleanup() {
|
||||
RcxTooltip::instance()->dismiss();
|
||||
QCoreApplication::processEvents();
|
||||
m_sim->tooltipEventCount = 0;
|
||||
m_sim->leaveEventCount = 0;
|
||||
m_sim->showForCallCount = 0;
|
||||
}
|
||||
|
||||
// Test 1: Post QHelpEvent → DarkApp simulator intercepts → RcxTooltip shows
|
||||
void testManualEventShowsTooltip() {
|
||||
LOG("\n--- testManualEventShowsTooltip ---\n");
|
||||
auto* tip = RcxTooltip::instance();
|
||||
|
||||
QPoint btnGlobal = m_btn->mapToGlobal(QPoint(60, 20));
|
||||
QCursor::setPos(btnGlobal);
|
||||
QCoreApplication::processEvents();
|
||||
|
||||
LOG(" posting QHelpEvent\n");
|
||||
QHelpEvent helpEvent(QEvent::ToolTip, QPoint(60, 20), btnGlobal);
|
||||
QApplication::sendEvent(m_btn, &helpEvent);
|
||||
QCoreApplication::processEvents();
|
||||
QTest::qWait(100);
|
||||
QCoreApplication::processEvents();
|
||||
|
||||
LOG(" sim: tooltipEvents=%d showForCalls=%d\n",
|
||||
m_sim->tooltipEventCount, m_sim->showForCallCount);
|
||||
LOG(" tip: visible=%d text='%s'\n",
|
||||
tip->isVisible(), qPrintable(tip->currentText()));
|
||||
|
||||
QVERIFY2(m_sim->tooltipEventCount > 0, "Event filter didn't see ToolTip event");
|
||||
QVERIFY2(m_sim->showForCallCount > 0, "showFor was never called");
|
||||
QVERIFY2(tip->isVisible(), "RcxTooltip not visible after manual event");
|
||||
QCOMPARE(tip->currentText(), QString("Start scanning memory"));
|
||||
|
||||
// Verify pixels
|
||||
tip->setWindowOpacity(1.0);
|
||||
QCoreApplication::processEvents();
|
||||
QImage img = tip->grab().toImage().convertToFormat(QImage::Format_ARGB32);
|
||||
QRect body = tip->bodyRect().adjusted(2, 2, -2, -2);
|
||||
int opaque = 0;
|
||||
for (int y = body.top(); y <= body.bottom(); ++y)
|
||||
for (int x = body.left(); x <= body.right(); ++x)
|
||||
if (qAlpha(img.pixel(x, y)) > 0) opaque++;
|
||||
LOG(" pixels: %d/%d opaque\n", opaque, body.width() * body.height());
|
||||
QVERIFY2(opaque > body.width() * body.height() / 2, "Body not rendered");
|
||||
|
||||
LOG("--- testManualEventShowsTooltip PASSED ---\n");
|
||||
}
|
||||
|
||||
// Test 2: Qt's native tooltip timer fires → our filter intercepts → tooltip shows
|
||||
void testNativeTimerShowsTooltip() {
|
||||
LOG("\n--- testNativeTimerShowsTooltip ---\n");
|
||||
auto* tip = RcxTooltip::instance();
|
||||
|
||||
// Move cursor away first
|
||||
QPoint away = m_window->mapToGlobal(QPoint(380, 10));
|
||||
QCursor::setPos(away);
|
||||
QTest::qWait(200);
|
||||
QCoreApplication::processEvents();
|
||||
|
||||
// Move to button
|
||||
QPoint btnCenter = m_btn->mapToGlobal(QPoint(60, 20));
|
||||
LOG(" moving cursor to (%d,%d)\n", btnCenter.x(), btnCenter.y());
|
||||
QCursor::setPos(btnCenter);
|
||||
|
||||
// Send Enter + MouseMove to kick the tooltip timer
|
||||
QEvent enterEv(QEvent::Enter);
|
||||
QApplication::sendEvent(m_btn, &enterEv);
|
||||
QMouseEvent moveEv(QEvent::MouseMove, QPointF(60, 20),
|
||||
m_btn->mapToGlobal(QPointF(60, 20)),
|
||||
Qt::NoButton, Qt::NoButton, Qt::NoModifier);
|
||||
QApplication::sendEvent(m_btn, &moveEv);
|
||||
|
||||
// Wait up to 2000ms for tooltip to appear
|
||||
LOG(" waiting for Qt tooltip timer...\n");
|
||||
bool appeared = false;
|
||||
for (int i = 0; i < 20; i++) {
|
||||
QTest::qWait(100);
|
||||
QCoreApplication::processEvents();
|
||||
if (m_sim->tooltipEventCount > 0) {
|
||||
LOG(" tooltip event at ~%dms! events=%d showFor=%d\n",
|
||||
(i+1)*100, m_sim->tooltipEventCount, m_sim->showForCallCount);
|
||||
appeared = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Process remaining events
|
||||
QTest::qWait(100);
|
||||
QCoreApplication::processEvents();
|
||||
|
||||
LOG(" final: events=%d showFor=%d visible=%d text='%s'\n",
|
||||
m_sim->tooltipEventCount, m_sim->showForCallCount,
|
||||
tip->isVisible(), qPrintable(tip->currentText()));
|
||||
|
||||
QVERIFY2(appeared, "Qt tooltip timer never fired (no ToolTip event in 2 seconds)");
|
||||
QVERIFY2(tip->isVisible(), "Tooltip not visible after native timer fired");
|
||||
|
||||
LOG("--- testNativeTimerShowsTooltip PASSED ---\n");
|
||||
}
|
||||
|
||||
// Test 3: Leave after tooltip shown → tooltip survives (cursor still in zone)
|
||||
void testLeaveSurvival() {
|
||||
LOG("\n--- testLeaveSurvival ---\n");
|
||||
auto* tip = RcxTooltip::instance();
|
||||
|
||||
QPoint btnCenter = m_btn->mapToGlobal(QPoint(60, 20));
|
||||
QCursor::setPos(btnCenter);
|
||||
QCoreApplication::processEvents();
|
||||
|
||||
// Show via manual event
|
||||
QHelpEvent helpEvent(QEvent::ToolTip, QPoint(60, 20), btnCenter);
|
||||
QApplication::sendEvent(m_btn, &helpEvent);
|
||||
QCoreApplication::processEvents();
|
||||
QTest::qWait(100);
|
||||
QCoreApplication::processEvents();
|
||||
QVERIFY(tip->isVisible());
|
||||
|
||||
// Send Leave (cursor still on button)
|
||||
LOG(" sending Leave while cursor on button\n");
|
||||
QEvent leaveEv(QEvent::Leave);
|
||||
QApplication::sendEvent(m_btn, &leaveEv);
|
||||
QTest::qWait(200);
|
||||
QCoreApplication::processEvents();
|
||||
|
||||
LOG(" after Leave+200ms: visible=%d leaves=%d\n",
|
||||
tip->isVisible(), m_sim->leaveEventCount);
|
||||
QVERIFY2(tip->isVisible(), "Tooltip dismissed by spurious Leave");
|
||||
|
||||
LOG("--- testLeaveSurvival PASSED ---\n");
|
||||
}
|
||||
|
||||
// Test 4: Switch between widgets
|
||||
void testWidgetSwitch() {
|
||||
LOG("\n--- testWidgetSwitch ---\n");
|
||||
auto* tip = RcxTooltip::instance();
|
||||
|
||||
// Show on btn1
|
||||
QPoint btn1Center = m_btn->mapToGlobal(QPoint(60, 20));
|
||||
QCursor::setPos(btn1Center);
|
||||
QCoreApplication::processEvents();
|
||||
QHelpEvent ev1(QEvent::ToolTip, QPoint(60, 20), btn1Center);
|
||||
QApplication::sendEvent(m_btn, &ev1);
|
||||
QCoreApplication::processEvents();
|
||||
QTest::qWait(100);
|
||||
QVERIFY(tip->isVisible());
|
||||
QCOMPARE(tip->currentText(), QString("Start scanning memory"));
|
||||
QPoint pos1 = tip->pos();
|
||||
|
||||
// Switch to btn2
|
||||
QPoint btn2Center = m_btn2->mapToGlobal(QPoint(60, 20));
|
||||
QCursor::setPos(btn2Center);
|
||||
QCoreApplication::processEvents();
|
||||
QHelpEvent ev2(QEvent::ToolTip, QPoint(60, 20), btn2Center);
|
||||
QApplication::sendEvent(m_btn2, &ev2);
|
||||
QCoreApplication::processEvents();
|
||||
QTest::qWait(100);
|
||||
|
||||
LOG(" after switch: visible=%d text='%s' pos=(%d,%d)\n",
|
||||
tip->isVisible(), qPrintable(tip->currentText()),
|
||||
tip->x(), tip->y());
|
||||
QVERIFY(tip->isVisible());
|
||||
QCOMPARE(tip->currentText(), QString("Copy to clipboard"));
|
||||
QVERIFY(tip->pos() != pos1);
|
||||
|
||||
LOG("--- testWidgetSwitch PASSED ---\n");
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_MAIN(TestTooltipEvent)
|
||||
#include "test_tooltip_event.moc"
|
||||
253
tests/test_tooltip_ui.cpp
Normal file
253
tests/test_tooltip_ui.cpp
Normal file
@@ -0,0 +1,253 @@
|
||||
// Integration test: simulates the full tooltip flow as DarkApp would see it.
|
||||
// Posts QHelpEvent (ToolTip), sends Leave events, verifies RcxTooltip behavior
|
||||
// with fprintf at every stage so we can see exactly what happens.
|
||||
|
||||
#include <QtTest>
|
||||
#include <QApplication>
|
||||
#include <QPushButton>
|
||||
#include <QHelpEvent>
|
||||
#include <QScreen>
|
||||
#include <QImage>
|
||||
#include "rcxtooltip.h"
|
||||
#include "themes/thememanager.h"
|
||||
#include <cstdio>
|
||||
|
||||
using namespace rcx;
|
||||
|
||||
static void LOG(const char* fmt, ...) {
|
||||
va_list ap;
|
||||
va_start(ap, fmt);
|
||||
vfprintf(stdout, fmt, ap);
|
||||
va_end(ap);
|
||||
fflush(stdout);
|
||||
}
|
||||
|
||||
// Simulates what DarkApp::notify does when a ToolTip event arrives
|
||||
static bool simulateDarkAppToolTip(QWidget* w) {
|
||||
QString tip = w->toolTip();
|
||||
LOG(" [darkapp] widget='%s' class=%s tip='%s'\n",
|
||||
qPrintable(w->objectName()), w->metaObject()->className(),
|
||||
qPrintable(tip));
|
||||
if (!tip.isEmpty()) {
|
||||
LOG(" [darkapp] calling RcxTooltip::showFor\n");
|
||||
RcxTooltip::instance()->showFor(w, tip);
|
||||
LOG(" [darkapp] showFor returned, visible=%d opacity=%.2f pos=(%d,%d) size=%dx%d\n",
|
||||
RcxTooltip::instance()->isVisible(),
|
||||
RcxTooltip::instance()->windowOpacity(),
|
||||
RcxTooltip::instance()->x(), RcxTooltip::instance()->y(),
|
||||
RcxTooltip::instance()->width(), RcxTooltip::instance()->height());
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Simulates what DarkApp::notify does when a Leave event arrives
|
||||
static void simulateDarkAppLeave(QWidget* w) {
|
||||
auto* tip = RcxTooltip::instance();
|
||||
if (tip->isVisible() && tip->currentTrigger() == w) {
|
||||
LOG(" [darkapp] Leave on trigger — calling scheduleDismiss\n");
|
||||
tip->scheduleDismiss();
|
||||
LOG(" [darkapp] after scheduleDismiss: visible=%d\n", tip->isVisible());
|
||||
} else {
|
||||
LOG(" [darkapp] Leave ignored (visible=%d trigger_match=%d)\n",
|
||||
tip->isVisible(), tip->currentTrigger() == w);
|
||||
}
|
||||
}
|
||||
|
||||
class TestTooltipUI : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
private:
|
||||
QWidget* m_window = nullptr;
|
||||
QPushButton* m_btn = nullptr;
|
||||
QPushButton* m_btn2 = nullptr;
|
||||
|
||||
private slots:
|
||||
void initTestCase() {
|
||||
LOG("=== TestTooltipUI starting ===\n");
|
||||
|
||||
m_window = new QWidget;
|
||||
m_window->setFixedSize(400, 300);
|
||||
QScreen* scr = QApplication::primaryScreen();
|
||||
QRect avail = scr->availableGeometry();
|
||||
m_window->move(avail.center() - QPoint(200, 150));
|
||||
|
||||
m_btn = new QPushButton("Scan", m_window);
|
||||
m_btn->setToolTip("Start scanning memory");
|
||||
m_btn->setFixedSize(80, 28);
|
||||
m_btn->move(160, 140);
|
||||
m_btn->setObjectName("btnScan");
|
||||
|
||||
m_btn2 = new QPushButton("Copy", m_window);
|
||||
m_btn2->setToolTip("Copy address to clipboard");
|
||||
m_btn2->setFixedSize(80, 28);
|
||||
m_btn2->move(260, 140);
|
||||
m_btn2->setObjectName("btnCopy");
|
||||
|
||||
m_window->show();
|
||||
QVERIFY(QTest::qWaitForWindowExposed(m_window));
|
||||
LOG(" window shown at (%d,%d)\n", m_window->x(), m_window->y());
|
||||
}
|
||||
|
||||
void cleanupTestCase() {
|
||||
RcxTooltip::instance()->dismiss();
|
||||
delete m_window;
|
||||
LOG("=== TestTooltipUI finished ===\n");
|
||||
}
|
||||
|
||||
void cleanup() {
|
||||
RcxTooltip::instance()->dismiss();
|
||||
QCoreApplication::processEvents();
|
||||
}
|
||||
|
||||
// ─── Test 1: Full tooltip lifecycle with event simulation ───
|
||||
void testFullLifecycle() {
|
||||
LOG("\n--- testFullLifecycle ---\n");
|
||||
auto* tip = RcxTooltip::instance();
|
||||
|
||||
// Step 1: Post a ToolTip event (what Qt does after hover delay)
|
||||
LOG("Step 1: Posting ToolTip event to btn\n");
|
||||
QPoint btnCenter = m_btn->mapToGlobal(QPoint(40, 14));
|
||||
LOG(" btn global center: (%d,%d)\n", btnCenter.x(), btnCenter.y());
|
||||
|
||||
// Move real cursor to button center
|
||||
QCursor::setPos(btnCenter);
|
||||
QCoreApplication::processEvents();
|
||||
LOG(" cursor moved to button\n");
|
||||
|
||||
// Simulate what DarkApp does on ToolTip event
|
||||
bool handled = simulateDarkAppToolTip(m_btn);
|
||||
QVERIFY2(handled, "DarkApp should have handled the tooltip");
|
||||
|
||||
// Process events (paint, animation start)
|
||||
QCoreApplication::processEvents();
|
||||
QTest::qWait(100); // let fade-in animation run
|
||||
QCoreApplication::processEvents();
|
||||
|
||||
LOG("Step 2: Check tooltip state after 100ms\n");
|
||||
LOG(" visible=%d opacity=%.2f text='%s'\n",
|
||||
tip->isVisible(), tip->windowOpacity(),
|
||||
qPrintable(tip->currentText()));
|
||||
LOG(" pos=(%d,%d) size=%dx%d\n",
|
||||
tip->x(), tip->y(), tip->width(), tip->height());
|
||||
LOG(" arrowDown=%d arrowX=%d bodyRect=(%d,%d %dx%d)\n",
|
||||
tip->arrowPointsDown(), tip->arrowLocalX(),
|
||||
tip->bodyRect().x(), tip->bodyRect().y(),
|
||||
tip->bodyRect().width(), tip->bodyRect().height());
|
||||
|
||||
QVERIFY2(tip->isVisible(), "Tooltip should be visible after showFor + 100ms");
|
||||
QCOMPARE(tip->currentText(), QString("Start scanning memory"));
|
||||
|
||||
// Step 3: Grab pixels and verify rendering
|
||||
LOG("Step 3: Verify rendering\n");
|
||||
tip->setWindowOpacity(1.0);
|
||||
QCoreApplication::processEvents();
|
||||
|
||||
QImage img = tip->grab().toImage().convertToFormat(QImage::Format_ARGB32);
|
||||
LOG(" grabbed image: %dx%d format=%d\n", img.width(), img.height(), img.format());
|
||||
|
||||
int opaquePixels = 0;
|
||||
QRect body = tip->bodyRect().adjusted(2, 2, -2, -2);
|
||||
for (int y = body.top(); y <= body.bottom(); ++y)
|
||||
for (int x = body.left(); x <= body.right(); ++x)
|
||||
if (qAlpha(img.pixel(x, y)) > 0)
|
||||
++opaquePixels;
|
||||
int totalPixels = body.width() * body.height();
|
||||
LOG(" body opaque pixels: %d / %d (%.1f%%)\n",
|
||||
opaquePixels, totalPixels,
|
||||
totalPixels > 0 ? 100.0 * opaquePixels / totalPixels : 0.0);
|
||||
|
||||
QVERIFY2(opaquePixels > totalPixels / 2,
|
||||
qPrintable(QStringLiteral("Only %1/%2 opaque pixels in body — tooltip not rendering")
|
||||
.arg(opaquePixels).arg(totalPixels)));
|
||||
|
||||
// Step 4: Simulate Leave event (spurious — cursor still on button)
|
||||
LOG("Step 4: Simulate spurious Leave (cursor still on button)\n");
|
||||
simulateDarkAppLeave(m_btn);
|
||||
QTest::qWait(200);
|
||||
QCoreApplication::processEvents();
|
||||
LOG(" after 200ms: visible=%d\n", tip->isVisible());
|
||||
|
||||
QVERIFY2(tip->isVisible(),
|
||||
"Tooltip dismissed by spurious Leave — geometry check failed");
|
||||
|
||||
// Step 5: Move cursor away and simulate real Leave
|
||||
LOG("Step 5: Move cursor away, simulate real Leave\n");
|
||||
QScreen* scr = QApplication::primaryScreen();
|
||||
QPoint farAway = scr->availableGeometry().bottomRight() - QPoint(50, 50);
|
||||
QCursor::setPos(farAway);
|
||||
QCoreApplication::processEvents();
|
||||
LOG(" cursor at (%d,%d)\n", farAway.x(), farAway.y());
|
||||
|
||||
simulateDarkAppLeave(m_btn);
|
||||
QTest::qWait(200);
|
||||
QCoreApplication::processEvents();
|
||||
LOG(" after 200ms: visible=%d\n", tip->isVisible());
|
||||
|
||||
QVERIFY2(!tip->isVisible(),
|
||||
"Tooltip should be dismissed when cursor truly left the zone");
|
||||
|
||||
// Step 6: Re-show on different widget
|
||||
LOG("Step 6: Re-show on different widget\n");
|
||||
QPoint btn2Center = m_btn2->mapToGlobal(QPoint(40, 14));
|
||||
QCursor::setPos(btn2Center);
|
||||
QCoreApplication::processEvents();
|
||||
|
||||
handled = simulateDarkAppToolTip(m_btn2);
|
||||
QVERIFY(handled);
|
||||
QCoreApplication::processEvents();
|
||||
QTest::qWait(100);
|
||||
QCoreApplication::processEvents();
|
||||
|
||||
LOG(" visible=%d text='%s'\n", tip->isVisible(), qPrintable(tip->currentText()));
|
||||
QVERIFY(tip->isVisible());
|
||||
QCOMPARE(tip->currentText(), QString("Copy address to clipboard"));
|
||||
|
||||
LOG("--- testFullLifecycle PASSED ---\n");
|
||||
}
|
||||
|
||||
// ─── Test 2: Rapid widget switching (no dismiss between) ───
|
||||
void testRapidSwitch() {
|
||||
LOG("\n--- testRapidSwitch ---\n");
|
||||
auto* tip = RcxTooltip::instance();
|
||||
|
||||
QCursor::setPos(m_btn->mapToGlobal(QPoint(40, 14)));
|
||||
QCoreApplication::processEvents();
|
||||
simulateDarkAppToolTip(m_btn);
|
||||
QCoreApplication::processEvents();
|
||||
QTest::qWait(50);
|
||||
|
||||
LOG(" switch to btn2 immediately\n");
|
||||
QCursor::setPos(m_btn2->mapToGlobal(QPoint(40, 14)));
|
||||
QCoreApplication::processEvents();
|
||||
simulateDarkAppToolTip(m_btn2);
|
||||
QCoreApplication::processEvents();
|
||||
QTest::qWait(100);
|
||||
QCoreApplication::processEvents();
|
||||
|
||||
LOG(" visible=%d text='%s'\n", tip->isVisible(), qPrintable(tip->currentText()));
|
||||
QVERIFY(tip->isVisible());
|
||||
QCOMPARE(tip->currentText(), QString("Copy address to clipboard"));
|
||||
LOG("--- testRapidSwitch PASSED ---\n");
|
||||
}
|
||||
|
||||
// ─── Test 3: Widget with no tooltip ───
|
||||
void testNoTooltipWidget() {
|
||||
LOG("\n--- testNoTooltipWidget ---\n");
|
||||
QPushButton noTip("NoTip", m_window);
|
||||
noTip.setFixedSize(80, 28);
|
||||
noTip.move(50, 50);
|
||||
noTip.show();
|
||||
// No setToolTip called
|
||||
|
||||
auto* tip = RcxTooltip::instance();
|
||||
bool handled = simulateDarkAppToolTip(&noTip);
|
||||
LOG(" handled=%d visible=%d\n", handled, tip->isVisible());
|
||||
QVERIFY(!handled);
|
||||
QVERIFY(!tip->isVisible());
|
||||
LOG("--- testNoTooltipWidget PASSED ---\n");
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_MAIN(TestTooltipUI)
|
||||
#include "test_tooltip_ui.moc"
|
||||
Reference in New Issue
Block a user