Files
archived-Reclass/tests/test_editor.cpp
IChooseYou 483f87cfbd feat: type hints green [bracketed] notation, workspace cleanup, unique naming
- Type inference hints now show value-first with bracketed type in comment
  green: "0x7ff718570000 [ptr64]", "6, 16 [int32_t×2]"
- Raise hint threshold to strong-only (score >= 75%)
- Remove Bool inference, widen Int16 range to ±16384
- Workspace: remove dead WorkspaceProxy, fix null deref, debounce search,
  cache icons, add pinning support
- Unique naming: UnnamedClass0/UnnamedEnum1 with global counter
- Footer buttons: +10h +100h +1000h replacing +1024
- MCP: project lifecycle API, snapshot provider fix
2026-03-09 10:39:22 -06:00

2900 lines
118 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include <QtTest/QTest>
#include <QtTest/QSignalSpy>
#include <QApplication>
#include <QKeyEvent>
#include <QFocusEvent>
#include <QMouseEvent>
#include <QFile>
#include <QMenu>
#include <QProxyStyle>
#include <QStyleOption>
#include <QImage>
#include <QPainter>
#include <QCursor>
#include <QScreen>
#include <QMainWindow>
#include <QStatusBar>
#include <QPushButton>
#include <QButtonGroup>
#include <QLabel>
#include <QLayout>
#include <QHBoxLayout>
#include <QScrollBar>
#include <Qsci/qsciscintilla.h>
#include <Qsci/qsciscintillabase.h>
#include "editor.h"
#include "core.h"
using namespace rcx;
// ── Cursor test helpers ──
static Qt::CursorShape viewportCursor(RcxEditor* editor) {
return editor->scintilla()->viewport()->cursor().shape();
}
static QPoint colToViewport(QsciScintilla* sci, int line, int col) {
long pos = sci->SendScintilla(QsciScintillaBase::SCI_FINDCOLUMN,
(unsigned long)line, (long)col);
int x = (int)sci->SendScintilla(QsciScintillaBase::SCI_POINTXFROMPOSITION, 0, pos);
int y = (int)sci->SendScintilla(QsciScintillaBase::SCI_POINTYFROMPOSITION, 0, pos);
return QPoint(x, y);
}
static void sendMouseMove(QWidget* viewport, const QPoint& pos) {
QMouseEvent move(QEvent::MouseMove, QPointF(pos), QPointF(pos),
Qt::NoButton, Qt::NoButton, Qt::NoModifier);
QApplication::sendEvent(viewport, &move);
}
static void sendLeftClick(QWidget* viewport, const QPoint& pos) {
QMouseEvent press(QEvent::MouseButtonPress, QPointF(pos), QPointF(pos),
Qt::LeftButton, Qt::LeftButton, Qt::NoModifier);
QApplication::sendEvent(viewport, &press);
QMouseEvent release(QEvent::MouseButtonRelease, QPointF(pos), QPointF(pos),
Qt::LeftButton, Qt::NoButton, Qt::NoModifier);
QApplication::sendEvent(viewport, &release);
}
// 0x7D0 bytes of PEB-like data with recognizable values at key offsets
static BufferProvider makeTestProvider() {
QByteArray data(0x7D0, '\0');
auto w8 = [&](int off, uint8_t v) { data[off] = (char)v; };
auto w16 = [&](int off, uint16_t v) { memcpy(data.data()+off, &v, 2); };
auto w32 = [&](int off, uint32_t v) { memcpy(data.data()+off, &v, 4); };
auto w64 = [&](int off, uint64_t v) { memcpy(data.data()+off, &v, 8); };
w8 (0x002, 1); // BeingDebugged
w8 (0x003, 0x04); // BitField
w64(0x008, 0xFFFFFFFFFFFFFFFFULL); // Mutant (-1)
w64(0x010, 0x00007FF6DE120000ULL); // ImageBaseAddress
w64(0x018, 0x00007FFE3B8B53C0ULL); // Ldr
w64(0x020, 0x000001A4C3E20F90ULL); // ProcessParameters
w64(0x028, 0x0000000000000000ULL); // SubSystemData
w64(0x030, 0x000001A4C3D40000ULL); // ProcessHeap
w64(0x038, 0x00007FFE3B8D4260ULL); // FastPebLock
w64(0x040, 0x0000000000000000ULL); // AtlThunkSListPtr
w64(0x048, 0x0000000000000000ULL); // IFEOKey
w32(0x050, 0x01); // CrossProcessFlags
w64(0x058, 0x00007FFE3B720000ULL); // KernelCallbackTable
w32(0x060, 0); // SystemReserved
w32(0x064, 0); // AtlThunkSListPtr32
w64(0x068, 0x00007FFE3E570000ULL); // ApiSetMap
w32(0x070, 0); // TlsExpansionCounter
w64(0x078, 0x00007FFE3B8D3F50ULL); // TlsBitmap
w32(0x080, 0x00000003); // TlsBitmapBits[0]
w32(0x084, 0x00000000); // TlsBitmapBits[1]
w64(0x088, 0x00007FFE38800000ULL); // ReadOnlySharedMemoryBase
w64(0x090, 0x00007FFE38820000ULL); // SharedData
w64(0x098, 0x00007FFE388A0000ULL); // ReadOnlyStaticServerData
w64(0x0A0, 0x00007FFE3B8D1000ULL); // AnsiCodePageData
w64(0x0A8, 0x00007FFE3B8D2040ULL); // OemCodePageData
w64(0x0B0, 0x00007FFE3B8CE020ULL); // UnicodeCaseTableData
w32(0x0B8, 8); // NumberOfProcessors
w32(0x0BC, 0x70); // NtGlobalFlag
w64(0x0C0, 0xFFFFFFFF7C91E000ULL); // CriticalSectionTimeout
w64(0x0C8, 0x0000000000100000ULL); // HeapSegmentReserve
w64(0x0D0, 0x0000000000002000ULL); // HeapSegmentCommit
w64(0x0D8, 0x0000000000040000ULL); // HeapDeCommitTotalFreeThreshold
w64(0x0E0, 0x0000000000001000ULL); // HeapDeCommitFreeBlockThreshold
w32(0x0E8, 4); // NumberOfHeaps
w32(0x0EC, 16); // MaximumNumberOfHeaps
w64(0x0F0, 0x000001A4C3D40688ULL); // ProcessHeaps
w64(0x0F8, 0x00007FFE388B0000ULL); // GdiSharedHandleTable
w64(0x100, 0x0000000000000000ULL); // ProcessStarterHelper
w32(0x108, 0); // GdiDCAttributeList
w64(0x110, 0x00007FFE3B8D42E8ULL); // LoaderLock
w32(0x118, 10); // OSMajorVersion
w32(0x11C, 0); // OSMinorVersion
w16(0x120, 19045); // OSBuildNumber
w16(0x122, 0); // OSCSDVersion
w32(0x124, 2); // OSPlatformId
w32(0x128, 3); // ImageSubsystem (CUI)
w32(0x12C, 10); // ImageSubsystemMajorVersion
w32(0x130, 0); // ImageSubsystemMinorVersion
w64(0x138, 0x00000000000000FFULL); // ActiveProcessAffinityMask
w64(0x230, 0x0000000000000000ULL); // PostProcessInitRoutine
w64(0x238, 0x00007FFE3B8D3F70ULL); // TlsExpansionBitmap
w32(0x2C0, 1); // SessionId
w64(0x2C8, 0x0000000000000000ULL); // AppCompatFlags
w64(0x2D0, 0x0000000000000000ULL); // AppCompatFlagsUser
w64(0x2D8, 0x0000000000000000ULL); // pShimData
w64(0x2E0, 0x0000000000000000ULL); // AppCompatInfo
w16(0x2E8, 0); // CSDVersion.Length
w16(0x2EA, 0); // CSDVersion.MaximumLength
w64(0x2F0, 0x0000000000000000ULL); // CSDVersion.Buffer
w64(0x2F8, 0x000001A4C3E21000ULL); // ActivationContextData
w64(0x300, 0x000001A4C3E22000ULL); // ProcessAssemblyStorageMap
w64(0x308, 0x00007FFE38840000ULL); // SystemDefaultActivationContextData
w64(0x310, 0x00007FFE38850000ULL); // SystemAssemblyStorageMap
w64(0x318, 0x0000000000002000ULL); // MinimumStackCommit
w64(0x330, 0x0000000000000000ULL); // PatchLoaderData
w64(0x338, 0x0000000000000000ULL); // ChpeV2ProcessInfo
w32(0x340, 0); // AppModelFeatureState
w16(0x34C, 1252); // ActiveCodePage
w16(0x34E, 437); // OemCodePage
w16(0x350, 0); // UseCaseMapping
w16(0x352, 0); // UnusedNlsField
w64(0x358, 0x000001A4C3E30000ULL); // WerRegistrationData
w64(0x360, 0x0000000000000000ULL); // WerShipAssertPtr
w64(0x368, 0x0000000000000000ULL); // EcCodeBitMap
w64(0x370, 0x0000000000000000ULL); // pImageHeaderHash
w32(0x378, 0); // TracingFlags
w64(0x380, 0x00007FFE38890000ULL); // CsrServerReadOnlySharedMemoryBase
w64(0x388, 0x0000000000000000ULL); // TppWorkerpListLock
w64(0x390, 0x000000D87B5E5390ULL); // TppWorkerpList.Flink (self)
w64(0x398, 0x000000D87B5E5390ULL); // TppWorkerpList.Blink (self)
w64(0x7A0, 0x0000000000000000ULL); // TelemetryCoverageHeader
w32(0x7A8, 0); // CloudFileFlags
w32(0x7AC, 0); // CloudFileDiagFlags
w8 (0x7B0, 0); // PlaceholderCompatibilityMode
w64(0x7B8, 0x00007FFE38860000ULL); // LeapSecondData
w32(0x7C0, 0); // LeapSecondFlags
w32(0x7C4, 0); // NtGlobalFlag2
w64(0x7C8, 0x0000000000000000ULL); // ExtendedFeatureDisableMask
return BufferProvider(data, "peb_snapshot.bin");
}
// Build the full _PEB64 tree (0x7D0 bytes), unions mapped to first member
static NodeTree makeTestTree() {
NodeTree tree;
tree.baseAddress = 0;
// Root struct
Node root;
root.kind = NodeKind::Struct;
root.structTypeName = "_PEB64";
root.name = "Peb";
root.parentId = 0;
root.offset = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
// Helpers
auto field = [&](int off, NodeKind k, const char* name) {
Node n; n.kind = k; n.name = name;
n.parentId = rootId; n.offset = off;
tree.addNode(n);
};
auto pad = [&](int off, int /*len*/, const char* name) {
// 4-byte padding → Hex32 (all usages in this test pass len=4)
Node n; n.kind = NodeKind::Hex32; n.name = name;
n.parentId = rootId; n.offset = off;
tree.addNode(n);
};
auto arr = [&](int off, NodeKind ek, int len, const char* name) {
Node n; n.kind = NodeKind::Array; n.name = name;
n.parentId = rootId; n.offset = off;
n.arrayLen = len; n.elementKind = ek;
tree.addNode(n);
};
auto sub = [&](int off, const char* ty, const char* name) -> uint64_t {
Node n; n.kind = NodeKind::Struct; n.structTypeName = ty; n.name = name;
n.parentId = rootId; n.offset = off;
int idx = tree.addNode(n); return tree.nodes[idx].id;
};
// ── 0x000 0x007 ──
field(0x000, NodeKind::UInt8, "InheritedAddressSpace");
field(0x001, NodeKind::UInt8, "ReadImageFileExecOptions");
field(0x002, NodeKind::UInt8, "BeingDebugged");
field(0x003, NodeKind::UInt8, "BitField"); // union → first member
pad (0x004, 4, "Padding0");
// ── 0x008 0x04F ──
field(0x008, NodeKind::Pointer64, "Mutant");
field(0x010, NodeKind::Pointer64, "ImageBaseAddress");
field(0x018, NodeKind::Pointer64, "Ldr");
field(0x020, NodeKind::Pointer64, "ProcessParameters");
field(0x028, NodeKind::Pointer64, "SubSystemData");
field(0x030, NodeKind::Pointer64, "ProcessHeap");
field(0x038, NodeKind::Pointer64, "FastPebLock");
field(0x040, NodeKind::Pointer64, "AtlThunkSListPtr");
field(0x048, NodeKind::Pointer64, "IFEOKey");
// ── 0x050 0x07F ──
field(0x050, NodeKind::UInt32, "CrossProcessFlags"); // union → first member
pad (0x054, 4, "Padding1");
field(0x058, NodeKind::Pointer64, "KernelCallbackTable"); // union → first member
field(0x060, NodeKind::UInt32, "SystemReserved");
field(0x064, NodeKind::UInt32, "AtlThunkSListPtr32");
field(0x068, NodeKind::Pointer64, "ApiSetMap");
field(0x070, NodeKind::UInt32, "TlsExpansionCounter");
pad (0x074, 4, "Padding2");
field(0x078, NodeKind::Pointer64, "TlsBitmap");
arr (0x080, NodeKind::UInt32, 2, "TlsBitmapBits");
// ── 0x088 0x0BF ──
field(0x088, NodeKind::Pointer64, "ReadOnlySharedMemoryBase");
field(0x090, NodeKind::Pointer64, "SharedData");
field(0x098, NodeKind::Pointer64, "ReadOnlyStaticServerData");
field(0x0A0, NodeKind::Pointer64, "AnsiCodePageData");
field(0x0A8, NodeKind::Pointer64, "OemCodePageData");
field(0x0B0, NodeKind::Pointer64, "UnicodeCaseTableData");
field(0x0B8, NodeKind::UInt32, "NumberOfProcessors");
field(0x0BC, NodeKind::Hex32, "NtGlobalFlag");
// ── 0x0C0 0x0EF ──
field(0x0C0, NodeKind::UInt64, "CriticalSectionTimeout"); // _LARGE_INTEGER union
field(0x0C8, NodeKind::UInt64, "HeapSegmentReserve");
field(0x0D0, NodeKind::UInt64, "HeapSegmentCommit");
field(0x0D8, NodeKind::UInt64, "HeapDeCommitTotalFreeThreshold");
field(0x0E0, NodeKind::UInt64, "HeapDeCommitFreeBlockThreshold");
field(0x0E8, NodeKind::UInt32, "NumberOfHeaps");
field(0x0EC, NodeKind::UInt32, "MaximumNumberOfHeaps");
// ── 0x0F0 0x13F ──
field(0x0F0, NodeKind::Pointer64, "ProcessHeaps");
field(0x0F8, NodeKind::Pointer64, "GdiSharedHandleTable");
field(0x100, NodeKind::Pointer64, "ProcessStarterHelper");
field(0x108, NodeKind::UInt32, "GdiDCAttributeList");
pad (0x10C, 4, "Padding3");
field(0x110, NodeKind::Pointer64, "LoaderLock");
field(0x118, NodeKind::UInt32, "OSMajorVersion");
field(0x11C, NodeKind::UInt32, "OSMinorVersion");
field(0x120, NodeKind::UInt16, "OSBuildNumber");
field(0x122, NodeKind::UInt16, "OSCSDVersion");
field(0x124, NodeKind::UInt32, "OSPlatformId");
field(0x128, NodeKind::UInt32, "ImageSubsystem");
field(0x12C, NodeKind::UInt32, "ImageSubsystemMajorVersion");
field(0x130, NodeKind::UInt32, "ImageSubsystemMinorVersion");
pad (0x134, 4, "Padding4");
field(0x138, NodeKind::UInt64, "ActiveProcessAffinityMask");
// ── 0x140 0x22F ──
arr (0x140, NodeKind::UInt32, 60, "GdiHandleBuffer");
// ── 0x230 0x2BF ──
field(0x230, NodeKind::Pointer64, "PostProcessInitRoutine");
field(0x238, NodeKind::Pointer64, "TlsExpansionBitmap");
arr (0x240, NodeKind::UInt32, 32, "TlsExpansionBitmapBits");
// ── 0x2C0 0x2E7 ──
field(0x2C0, NodeKind::UInt32, "SessionId");
pad (0x2C4, 4, "Padding5");
field(0x2C8, NodeKind::UInt64, "AppCompatFlags"); // _ULARGE_INTEGER union
field(0x2D0, NodeKind::UInt64, "AppCompatFlagsUser"); // _ULARGE_INTEGER union
field(0x2D8, NodeKind::Pointer64, "pShimData");
field(0x2E0, NodeKind::Pointer64, "AppCompatInfo");
// ── 0x2E8 0x2F7: _STRING64 CSDVersion (nested struct) ──
{
uint64_t sid = sub(0x2E8, "_STRING64", "CSDVersion");
Node n;
n.parentId = sid;
n.kind = NodeKind::UInt16; n.name = "Length"; n.offset = 0; tree.addNode(n);
n.kind = NodeKind::UInt16; n.name = "MaximumLength"; n.offset = 2; tree.addNode(n);
n.kind = NodeKind::Hex32; n.name = "Pad";
n.offset = 4; n.arrayLen = 1; tree.addNode(n);
n.kind = NodeKind::Pointer64; n.name = "Buffer"; n.offset = 8; n.arrayLen = 1;
tree.addNode(n);
}
// ── 0x2F8 0x31F ──
field(0x2F8, NodeKind::Pointer64, "ActivationContextData");
field(0x300, NodeKind::Pointer64, "ProcessAssemblyStorageMap");
field(0x308, NodeKind::Pointer64, "SystemDefaultActivationContextData");
field(0x310, NodeKind::Pointer64, "SystemAssemblyStorageMap");
field(0x318, NodeKind::UInt64, "MinimumStackCommit");
// ── 0x320 0x34B ──
arr (0x320, NodeKind::UInt64, 2, "SparePointers");
field(0x330, NodeKind::Pointer64, "PatchLoaderData");
field(0x338, NodeKind::Pointer64, "ChpeV2ProcessInfo");
field(0x340, NodeKind::UInt32, "AppModelFeatureState");
arr (0x344, NodeKind::UInt32, 2, "SpareUlongs");
field(0x34C, NodeKind::UInt16, "ActiveCodePage");
field(0x34E, NodeKind::UInt16, "OemCodePage");
field(0x350, NodeKind::UInt16, "UseCaseMapping");
field(0x352, NodeKind::UInt16, "UnusedNlsField");
// ── 0x354 0x37F (implicit padding + fields) ──
pad (0x354, 4, "Pad354");
field(0x358, NodeKind::Pointer64, "WerRegistrationData");
field(0x360, NodeKind::Pointer64, "WerShipAssertPtr");
field(0x368, NodeKind::Pointer64, "EcCodeBitMap");
field(0x370, NodeKind::Pointer64, "pImageHeaderHash");
field(0x378, NodeKind::UInt32, "TracingFlags"); // union → first member
pad (0x37C, 4, "Padding6");
// ── 0x380 0x39F ──
field(0x380, NodeKind::Pointer64, "CsrServerReadOnlySharedMemoryBase");
field(0x388, NodeKind::UInt64, "TppWorkerpListLock");
// ── 0x390 0x39F: LIST_ENTRY64 TppWorkerpList (nested struct) ──
{
uint64_t sid = sub(0x390, "LIST_ENTRY64", "TppWorkerpList");
Node n;
n.parentId = sid;
n.kind = NodeKind::Pointer64; n.name = "Flink"; n.offset = 0; tree.addNode(n);
n.kind = NodeKind::Pointer64; n.name = "Blink"; n.offset = 8; tree.addNode(n);
}
// ── 0x3A0 0x79F ──
arr (0x3A0, NodeKind::UInt64, 128, "WaitOnAddressHashTable");
// ── 0x7A0 0x7CF ──
field(0x7A0, NodeKind::Pointer64, "TelemetryCoverageHeader");
field(0x7A8, NodeKind::UInt32, "CloudFileFlags");
field(0x7AC, NodeKind::UInt32, "CloudFileDiagFlags");
field(0x7B0, NodeKind::Int8, "PlaceholderCompatibilityMode");
arr (0x7B1, NodeKind::Int8, 7, "PlaceholderCompatibilityModeReserved");
field(0x7B8, NodeKind::Pointer64, "LeapSecondData");
field(0x7C0, NodeKind::UInt32, "LeapSecondFlags"); // union → first member
field(0x7C4, NodeKind::UInt32, "NtGlobalFlag2");
field(0x7C8, NodeKind::UInt64, "ExtendedFeatureDisableMask");
return tree;
}
// ── Pointer expansion demo data ──
// Small tree with a working pointer that points within the buffer.
// Root struct "Demo" has a UInt32 "id" and Pointer64 "pChild" → ChildData.
// ChildData has UInt32 "x", UInt32 "y", Float "z".
struct PtrDemo {
NodeTree tree;
BufferProvider prov{QByteArray()};
uint64_t rootId = 0;
uint64_t childStructId = 0;
};
static PtrDemo makePtrDemo(bool collapsed = false, bool nullPtr = false) {
PtrDemo d;
d.tree.baseAddress = 0;
// Root struct
Node root;
root.kind = NodeKind::Struct;
root.structTypeName = "Demo";
root.name = "demo";
root.parentId = 0;
root.offset = 0;
int ri = d.tree.addNode(root);
d.rootId = d.tree.nodes[ri].id;
// id field at offset 0
{
Node n;
n.kind = NodeKind::UInt32;
n.name = "id";
n.parentId = d.rootId;
n.offset = 0;
d.tree.addNode(n);
}
// ChildData struct definition (separate root)
Node child;
child.kind = NodeKind::Struct;
child.structTypeName = "ChildData";
child.name = "ChildData";
child.parentId = 0;
child.offset = 200; // standalone rendering offset
int ci = d.tree.addNode(child);
d.childStructId = d.tree.nodes[ci].id;
{
Node n;
n.kind = NodeKind::UInt32; n.name = "x";
n.parentId = d.childStructId; n.offset = 0;
d.tree.addNode(n);
n.kind = NodeKind::UInt32; n.name = "y";
n.offset = 4;
d.tree.addNode(n);
n.kind = NodeKind::Float; n.name = "z";
n.offset = 8;
d.tree.addNode(n);
}
// Pointer at offset 8 → ChildData
{
Node ptr;
ptr.kind = NodeKind::Pointer64;
ptr.name = "pChild";
ptr.parentId = d.rootId;
ptr.offset = 8;
ptr.refId = d.childStructId;
ptr.collapsed = collapsed;
d.tree.addNode(ptr);
}
// Buffer: 128 bytes
QByteArray data(128, '\0');
uint32_t idVal = 42;
memcpy(data.data() + 0, &idVal, 4);
if (!nullPtr) {
uint64_t ptrVal = 64; // points to offset 64 in buffer
memcpy(data.data() + 8, &ptrVal, 8);
}
// Data at the pointer target (offset 64)
uint32_t xVal = 100; memcpy(data.data() + 64, &xVal, 4);
uint32_t yVal = 200; memcpy(data.data() + 68, &yVal, 4);
float zVal = 3.14f; memcpy(data.data() + 72, &zVal, 4);
d.prov = BufferProvider(data, "ptr_demo");
return d;
}
class TestEditor : public QObject {
Q_OBJECT
private:
RcxEditor* m_editor = nullptr;
ComposeResult m_result;
private slots:
void initTestCase() {
m_editor = new RcxEditor();
m_editor->resize(800, 600);
m_editor->show();
QVERIFY(QTest::qWaitForWindowExposed(m_editor));
NodeTree tree = makeTestTree();
BufferProvider prov = makeTestProvider();
m_result = compose(tree, prov);
m_editor->applyDocument(m_result);
}
void cleanupTestCase() {
delete m_editor;
}
// ── Test: CommandRow at line 0 rejects non-ADDR edits ──
void testCommandRowLineRejectsEdits() {
m_editor->applyDocument(m_result);
// Line 0 should be the CommandRow
const LineMeta* lm = m_editor->metaForLine(0);
QVERIFY(lm);
QCOMPARE(lm->lineKind, LineKind::CommandRow);
QCOMPARE(lm->nodeId, kCommandRowId);
QCOMPARE(lm->nodeIdx, -1);
// Type/Name/Value should be rejected on CommandRow
QVERIFY(!m_editor->beginInlineEdit(EditTarget::Type, 0));
QVERIFY(!m_editor->beginInlineEdit(EditTarget::Name, 0));
QVERIFY(!m_editor->beginInlineEdit(EditTarget::Value, 0));
QVERIFY(!m_editor->isEditing());
// Set CommandRow text with an ADDR value (simulates controller.updateCommandRow)
m_editor->setCommandRowText(
QStringLiteral("source\u25BE 0xD87B5E5000"));
// BaseAddress should be ALLOWED on CommandRow (ADDR field)
bool ok = m_editor->beginInlineEdit(EditTarget::BaseAddress, 0);
QVERIFY2(ok, "BaseAddress edit should be allowed on CommandRow");
QVERIFY(m_editor->isEditing());
m_editor->cancelInlineEdit();
// Source should be ALLOWED on CommandRow (SRC field)
ok = m_editor->beginInlineEdit(EditTarget::Source, 0);
QVERIFY2(ok, "Source edit should be allowed on CommandRow");
QVERIFY(m_editor->isEditing());
m_editor->cancelInlineEdit();
QApplication::processEvents(); // flush deferred showSourcePicker timer
}
// ── Test: inline edit lifecycle (begin → commit → re-edit) ──
void testInlineEditReEntry() {
// Move cursor to first data line (0=CommandRow, root header suppressed)
m_editor->scintilla()->setCursorPosition(kFirstDataLine, 0);
// Should not be editing
QVERIFY(!m_editor->isEditing());
// Begin edit on Name column
bool ok = m_editor->beginInlineEdit(EditTarget::Name, kFirstDataLine);
QVERIFY(ok);
QVERIFY(m_editor->isEditing());
// Cancel the edit
m_editor->cancelInlineEdit();
QVERIFY(!m_editor->isEditing());
// Re-apply document (simulates controller refresh)
m_editor->applyDocument(m_result);
// Should be able to edit again
ok = m_editor->beginInlineEdit(EditTarget::Name, kFirstDataLine);
QVERIFY(ok);
QVERIFY(m_editor->isEditing());
// Cancel again
m_editor->cancelInlineEdit();
QVERIFY(!m_editor->isEditing());
}
// ── Test: commit inline edit then re-edit same line ──
void testCommitThenReEdit() {
m_editor->applyDocument(m_result);
m_editor->scintilla()->setCursorPosition(kFirstDataLine, 0);
// Begin value edit
bool ok = m_editor->beginInlineEdit(EditTarget::Value, kFirstDataLine);
QVERIFY(ok);
QVERIFY(m_editor->isEditing());
// Simulate Enter key → commit (via signal spy)
QSignalSpy spy(m_editor, &RcxEditor::inlineEditCommitted);
QKeyEvent enter(QEvent::KeyPress, Qt::Key_Return, Qt::NoModifier);
QApplication::sendEvent(m_editor->scintilla(), &enter);
// Should have emitted commit signal and exited edit mode
QCOMPARE(spy.count(), 1);
QVERIFY(!m_editor->isEditing());
// Re-apply document (simulates refresh)
m_editor->applyDocument(m_result);
// Must be able to edit the same line again
ok = m_editor->beginInlineEdit(EditTarget::Value, kFirstDataLine);
QVERIFY(ok);
QVERIFY(m_editor->isEditing());
m_editor->cancelInlineEdit();
}
// ── Test: mouse click during edit commits it ──
void testMouseClickCommitsEdit() {
m_editor->applyDocument(m_result);
bool ok = m_editor->beginInlineEdit(EditTarget::Name, kFirstDataLine);
QVERIFY(ok);
QVERIFY(m_editor->isEditing());
// Simulate mouse click on viewport — should commit (save), not cancel
QSignalSpy commitSpy(m_editor, &RcxEditor::inlineEditCommitted);
QSignalSpy cancelSpy(m_editor, &RcxEditor::inlineEditCancelled);
QMouseEvent click(QEvent::MouseButtonPress, QPointF(10, 10),
QPointF(10, 10), Qt::LeftButton,
Qt::LeftButton, Qt::NoModifier);
QApplication::sendEvent(m_editor->scintilla()->viewport(), &click);
QVERIFY(!m_editor->isEditing());
QCOMPARE(commitSpy.count(), 1);
QCOMPARE(cancelSpy.count(), 0);
}
// ── Test: type edit emits typePickerRequested (popup-based, not inline edit) ──
void testTypeEditCancel() {
m_editor->applyDocument(m_result);
QSignalSpy spy(m_editor, &RcxEditor::typePickerRequested);
// Begin type edit on a field line — now handled by TypeSelectorPopup
bool ok = m_editor->beginInlineEdit(EditTarget::Type, kFirstDataLine);
QVERIFY(ok);
QCOMPARE(spy.count(), 1);
// Type editing uses popup, not inline edit state
QVERIFY(!m_editor->isEditing());
}
// ── Test: edit on header line (Name and Type valid, Value invalid) ──
void testHeaderLineEdit() {
m_editor->applyDocument(m_result);
// Root header is suppressed; find a nested struct header (e.g. CSDVersion)
int headerLine = -1;
for (int i = 0; i < m_result.meta.size(); i++) {
if (m_result.meta[i].lineKind == LineKind::Header &&
m_result.meta[i].foldHead) {
headerLine = i;
break;
}
}
QVERIFY2(headerLine >= 0, "Should have a nested struct header");
const LineMeta* lm = m_editor->metaForLine(headerLine);
QVERIFY(lm);
QCOMPARE(lm->lineKind, LineKind::Header);
// Scroll to header line to ensure visibility
m_editor->scintilla()->SendScintilla(
QsciScintillaBase::SCI_ENSUREVISIBLE, (unsigned long)headerLine);
m_editor->scintilla()->SendScintilla(
QsciScintillaBase::SCI_GOTOLINE, (unsigned long)headerLine);
QApplication::processEvents();
// Type edit on header should succeed (emits popup signal, not inline edit)
QSignalSpy typeSpy(m_editor, &RcxEditor::typePickerRequested);
bool ok = m_editor->beginInlineEdit(EditTarget::Type, headerLine);
QVERIFY(ok);
QCOMPARE(typeSpy.count(), 1);
// Name edit on header should succeed
ok = m_editor->beginInlineEdit(EditTarget::Name, headerLine);
QVERIFY(ok);
QVERIFY(m_editor->isEditing());
m_editor->cancelInlineEdit();
}
// ── Test: footer line rejects all edits ──
void testFooterLineEdit() {
m_editor->applyDocument(m_result);
// Find the footer line
int footerLine = -1;
for (int i = 0; i < m_result.meta.size(); i++) {
if (m_result.meta[i].lineKind == LineKind::Footer) {
footerLine = i;
break;
}
}
QVERIFY(footerLine >= 0);
QVERIFY(!m_editor->beginInlineEdit(EditTarget::Type, footerLine));
QVERIFY(!m_editor->beginInlineEdit(EditTarget::Name, footerLine));
QVERIFY(!m_editor->beginInlineEdit(EditTarget::Value, footerLine));
QVERIFY(!m_editor->isEditing());
}
// ── Test: parseValue accepts space-separated hex bytes ──
void testParseValueHexWithSpaces() {
bool ok;
// Hex8 with spaces (single byte, but test the .remove(' '))
QByteArray b = fmt::parseValue(NodeKind::Hex8, "4D", &ok);
QVERIFY(ok);
QCOMPARE((uint8_t)b[0], (uint8_t)0x4D);
// Hex32 with space-separated bytes (raw byte order, no endian conversion)
b = fmt::parseValue(NodeKind::Hex32, "DE AD BE EF", &ok);
QVERIFY(ok);
QCOMPARE(b.size(), 4);
QCOMPARE((uint8_t)b[0], (uint8_t)0xDE);
QCOMPARE((uint8_t)b[1], (uint8_t)0xAD);
QCOMPARE((uint8_t)b[2], (uint8_t)0xBE);
QCOMPARE((uint8_t)b[3], (uint8_t)0xEF);
// Hex64 with space-separated bytes
b = fmt::parseValue(NodeKind::Hex64, "4D 5A 90 00 00 00 00 00", &ok);
QVERIFY(ok);
QCOMPARE(b.size(), 8);
QCOMPARE((uint8_t)b[0], (uint8_t)0x4D);
QCOMPARE((uint8_t)b[1], (uint8_t)0x5A);
QCOMPARE((uint8_t)b[7], (uint8_t)0x00);
// Hex64 continuous - stores as native-endian (numeric value preserved)
b = fmt::parseValue(NodeKind::Hex64, "4D5A900000000000", &ok);
QVERIFY(ok);
uint64_t v64;
memcpy(&v64, b.data(), 8);
QCOMPARE(v64, (uint64_t)0x4D5A900000000000);
// Hex64 with 0x prefix and spaces
b = fmt::parseValue(NodeKind::Hex64, "0x4D 5A 90 00 00 00 00 00", &ok);
QVERIFY(ok);
}
// ── Test: type autocomplete accepts typed input and commits ──
void testTypeAutocompleteTypingAndCommit() {
m_editor->applyDocument(m_result);
QSignalSpy spy(m_editor, &RcxEditor::typePickerRequested);
// Type edit now emits typePickerRequested for TypeSelectorPopup
bool ok = m_editor->beginInlineEdit(EditTarget::Type, kFirstDataLine);
QVERIFY(ok);
QCOMPARE(spy.count(), 1);
// Verify signal carries valid nodeIdx (second arg)
QList<QVariant> args = spy.first();
QVERIFY(args.at(1).toInt() >= 0);
// No inline edit state — popup handles everything
QVERIFY(!m_editor->isEditing());
m_editor->applyDocument(m_result);
}
// ── Test: type edit click-away commits original (no change) ──
void testTypeEditClickAwayNoChange() {
m_editor->applyDocument(m_result);
QSignalSpy spy(m_editor, &RcxEditor::typePickerRequested);
// Type edit emits typePickerRequested (popup handles click-away)
bool ok = m_editor->beginInlineEdit(EditTarget::Type, kFirstDataLine);
QVERIFY(ok);
QCOMPARE(spy.count(), 1);
// No inline edit state — popup handles click-away behavior
QVERIFY(!m_editor->isEditing());
m_editor->applyDocument(m_result);
}
// ── Test: column span hit-testing for cursor shape ──
void testColumnSpanHitTest() {
m_editor->applyDocument(m_result);
// kFirstDataLine is a field line (UInt8), verify spans are valid
const LineMeta* lm = m_editor->metaForLine(kFirstDataLine);
QVERIFY(lm);
QCOMPARE(lm->lineKind, LineKind::Field);
// Type span should be valid for field lines
ColumnSpan ts = RcxEditor::typeSpan(*lm);
QVERIFY(ts.valid);
QVERIFY(ts.start < ts.end);
// Name span should be valid for field lines
ColumnSpan ns = RcxEditor::nameSpan(*lm);
QVERIFY(ns.valid);
QVERIFY(ns.start < ns.end);
// Value span should be valid for field lines
QString lineText;
int len = (int)m_editor->scintilla()->SendScintilla(
QsciScintillaBase::SCI_LINELENGTH, (unsigned long)kFirstDataLine);
QVERIFY(len > 0);
ColumnSpan vs = RcxEditor::valueSpan(*lm, len);
QVERIFY(vs.valid);
QVERIFY(vs.start < vs.end);
// Footer line should have no valid type/name spans
int footerLine = -1;
for (int i = 0; i < m_result.meta.size(); i++) {
if (m_result.meta[i].lineKind == LineKind::Footer) {
footerLine = i;
break;
}
}
QVERIFY(footerLine >= 0);
const LineMeta* flm = m_editor->metaForLine(footerLine);
QVERIFY(flm);
ColumnSpan fts = RcxEditor::typeSpan(*flm);
QVERIFY(!fts.valid);
ColumnSpan fns = RcxEditor::nameSpan(*flm);
QVERIFY(!fns.valid);
ColumnSpan fvs = RcxEditor::valueSpan(*flm, 10);
QVERIFY(!fvs.valid);
}
// ── Test: selectedNodeIndices ──
void testSelectedNodeIndices() {
m_editor->applyDocument(m_result);
// Put cursor on first field line (kFirstDataLine; 0=CommandRow)
m_editor->scintilla()->setCursorPosition(kFirstDataLine, 0);
QSet<int> sel = m_editor->selectedNodeIndices();
QCOMPARE(sel.size(), 1);
// The node index should match the first field
const LineMeta* lm = m_editor->metaForLine(kFirstDataLine);
QVERIFY(lm);
QVERIFY(sel.contains(lm->nodeIdx));
}
// ── Test: composed text does not contain "// base:" (moved to cmd bar) ──
void testBaseAddressDisplay() {
NodeTree tree = makeTestTree();
tree.baseAddress = 0x10;
BufferProvider prov = makeTestProvider();
ComposeResult result = compose(tree, prov);
m_editor->applyDocument(result);
// Root header is suppressed; verify no "// base:" anywhere in output
QVERIFY2(!result.text.contains("// base:"),
"Composed text should not contain '// base:' (consolidated into cmd bar)");
// kFirstDataLine should be the first field (root header suppressed)
const LineMeta* lm = m_editor->metaForLine(kFirstDataLine);
QVERIFY(lm);
QCOMPARE(lm->lineKind, LineKind::Field);
m_editor->applyDocument(m_result);
}
// ── Test: CommandRow ADDR span is valid ──
void testBaseAddressSpan() {
m_editor->applyDocument(m_result);
// Set CommandRow text with ADDR value (simulates controller)
m_editor->setCommandRowText(
QStringLiteral("source\u25BE 0xD87B5E5000"));
// Line 0 is CommandRow
const LineMeta* lm = m_editor->metaForLine(0);
QVERIFY(lm);
QCOMPARE(lm->lineKind, LineKind::CommandRow);
// Get CommandRow line text
QString lineText;
int len = (int)m_editor->scintilla()->SendScintilla(
QsciScintillaBase::SCI_LINELENGTH, (unsigned long)0);
if (len > 0) {
QByteArray buf(len + 1, '\0');
m_editor->scintilla()->SendScintilla(
QsciScintillaBase::SCI_GETLINE, (unsigned long)0, (void*)buf.data());
lineText = QString::fromUtf8(buf.constData(), len);
while (lineText.endsWith('\n') || lineText.endsWith('\r'))
lineText.chop(1);
}
// ADDR span should be valid (uses commandRowAddrSpan)
ColumnSpan as = commandRowAddrSpan(lineText);
QVERIFY2(as.valid, "ADDR span should be valid on CommandRow");
QVERIFY(as.start < as.end);
// The span should cover the hex address
QString spanText = lineText.mid(as.start, as.end - as.start);
QVERIFY2(spanText.contains("0x") || spanText.startsWith("0X"),
qPrintable("Span should contain hex address, got: " + spanText));
m_editor->applyDocument(m_result);
}
// ── Test: value edit commit fires signal with typed text ──
void testValueEditCommitUpdatesSignal() {
m_editor->applyDocument(m_result);
// kFirstDataLine = first UInt8 field (InheritedAddressSpace, root header suppressed)
const LineMeta* lm = m_editor->metaForLine(kFirstDataLine);
QVERIFY(lm);
QCOMPARE(lm->lineKind, LineKind::Field);
// Begin value edit
bool ok = m_editor->beginInlineEdit(EditTarget::Value, kFirstDataLine);
QVERIFY(ok);
QVERIFY(m_editor->isEditing());
// Select all text in the edit span and type replacement
QKeyEvent home(QEvent::KeyPress, Qt::Key_Home, Qt::NoModifier);
QApplication::sendEvent(m_editor->scintilla(), &home);
QKeyEvent end(QEvent::KeyPress, Qt::Key_End, Qt::ShiftModifier);
QApplication::sendEvent(m_editor->scintilla(), &end);
// Type "42" to replace selected text
for (QChar c : QString("42")) {
QKeyEvent key(QEvent::KeyPress, 0, Qt::NoModifier, QString(c));
QApplication::sendEvent(m_editor->scintilla(), &key);
}
QApplication::processEvents();
// Commit with Enter
QSignalSpy spy(m_editor, &RcxEditor::inlineEditCommitted);
QKeyEvent enter(QEvent::KeyPress, Qt::Key_Return, Qt::NoModifier);
QApplication::sendEvent(m_editor->scintilla(), &enter);
QCOMPARE(spy.count(), 1);
QVERIFY(!m_editor->isEditing());
// Verify the committed text contains what was typed.
// UInt8 values display as hex (e.g., "0x042"), so the typed "42" gets
// concatenated with the existing "0x0" prefix → "0x042".
// The important check: the signal fired with non-empty text.
QList<QVariant> args = spy.first();
QString committedText = args.at(3).toString().trimmed();
QVERIFY2(!committedText.isEmpty(),
"Committed text should not be empty");
m_editor->applyDocument(m_result);
}
// ── Test: base address edit begins on CommandRow (line 0) ──
void testBaseAddressEditBegins() {
m_editor->applyDocument(m_result);
// Set CommandRow text with ADDR value (simulates controller)
m_editor->setCommandRowText(
QStringLiteral("source\u25BE 0xD87B5E5000"));
// Begin base address edit on line 0 (CommandRow ADDR field)
bool ok = m_editor->beginInlineEdit(EditTarget::BaseAddress, 0);
QVERIFY2(ok, "Should be able to begin base address edit on CommandRow");
QVERIFY(m_editor->isEditing());
// Cancel and reset
m_editor->cancelInlineEdit();
m_editor->applyDocument(m_result);
}
// ── Test: cursor stays Arrow after left-click on a node ──
void testCursorAfterLeftClick() {
m_editor->applyDocument(m_result);
// Click on a field line at the indent area (col 0 — not over editable text)
QPoint clickPos = colToViewport(m_editor->scintilla(), kFirstDataLine, 0);
sendLeftClick(m_editor->scintilla()->viewport(), clickPos);
QApplication::processEvents();
// Cursor must be Arrow — QScintilla must NOT have set it to IBeam
QCOMPARE(viewportCursor(m_editor), Qt::ArrowCursor);
QVERIFY(!m_editor->isEditing());
}
// ── Test: cursor is IBeam only over trimmed name text, Arrow over padding ──
void testCursorShapeOverText() {
m_editor->applyDocument(m_result);
// kFirstDataLine is a field (UInt8 InheritedAddressSpace)
const LineMeta* lm = m_editor->metaForLine(kFirstDataLine);
QVERIFY(lm);
// Get the name span (padded to kColName width)
ColumnSpan ns = RcxEditor::nameSpan(*lm, lm->effectiveTypeW, lm->effectiveNameW);
QVERIFY(ns.valid);
// Move mouse to the start of the name span (should be over text)
QPoint textPos = colToViewport(m_editor->scintilla(), kFirstDataLine, ns.start + 1);
sendMouseMove(m_editor->scintilla()->viewport(), textPos);
QApplication::processEvents();
QCOMPARE(viewportCursor(m_editor), Qt::IBeamCursor);
// Move mouse to far padding area (past end of text, within padded span)
// The padded span ends at ns.end but the trimmed text is shorter
QPoint padPos = colToViewport(m_editor->scintilla(), kFirstDataLine, ns.end - 1);
sendMouseMove(m_editor->scintilla()->viewport(), padPos);
QApplication::processEvents();
// Should be Arrow (padding whitespace, not actual text)
QCOMPARE(viewportCursor(m_editor), Qt::ArrowCursor);
}
// ── Test: cursor is PointingHand over type column text ──
void testCursorShapeOverType() {
m_editor->applyDocument(m_result);
const LineMeta* lm = m_editor->metaForLine(kFirstDataLine);
QVERIFY(lm);
// Type span starts after the fold column + indent
ColumnSpan ts = RcxEditor::typeSpan(*lm, lm->effectiveTypeW);
QVERIFY(ts.valid);
// Move to start of type text (e.g. "uint8_t")
QPoint typePos = colToViewport(m_editor->scintilla(), kFirstDataLine, ts.start + 1);
sendMouseMove(m_editor->scintilla()->viewport(), typePos);
QApplication::processEvents();
QCOMPARE(viewportCursor(m_editor), Qt::PointingHandCursor);
}
// ── Test: cursor is PointingHand over fold column ──
void testCursorShapeInFoldColumn() {
m_editor->applyDocument(m_result);
QApplication::processEvents();
// Root header is suppressed; find a nested struct with foldHead
int foldLine = -1;
for (int i = 0; i < m_result.meta.size(); i++) {
if (m_result.meta[i].foldHead && m_result.meta[i].lineKind == LineKind::Header) {
foldLine = i;
break;
}
}
QVERIFY2(foldLine >= 0, "Should have at least one foldable struct header");
const LineMeta* lm = m_editor->metaForLine(foldLine);
QVERIFY(lm);
QVERIFY(lm->foldHead);
// Scroll to ensure the fold line is visible
m_editor->scintilla()->SendScintilla(
QsciScintillaBase::SCI_ENSUREVISIBLE, (unsigned long)foldLine);
m_editor->scintilla()->SendScintilla(
QsciScintillaBase::SCI_GOTOLINE, (unsigned long)foldLine);
QApplication::processEvents();
// Fold indicator is always at cols 0-2 (kFoldCol=3), regardless of depth
QPoint foldPos = colToViewport(m_editor->scintilla(), foldLine, 1);
QVERIFY2(foldPos.y() > 0, qPrintable(QString("Fold line %1 should be visible, got y=%2")
.arg(foldLine).arg(foldPos.y())));
sendMouseMove(m_editor->scintilla()->viewport(), foldPos);
QApplication::processEvents();
QCOMPARE(viewportCursor(m_editor), Qt::PointingHandCursor);
}
// ── Test: no IBeam after click then mouse-move to non-editable area ──
void testNoIBeamAfterClickThenMove() {
m_editor->applyDocument(m_result);
// Click on a field to select the node
const LineMeta* lm = m_editor->metaForLine(kFirstDataLine);
QVERIFY(lm);
ColumnSpan ns = RcxEditor::nameSpan(*lm, lm->effectiveTypeW, lm->effectiveNameW);
QVERIFY(ns.valid);
// Click in the name area (selects the node)
QPoint clickPos = colToViewport(m_editor->scintilla(), kFirstDataLine, ns.start + 1);
sendLeftClick(m_editor->scintilla()->viewport(), clickPos);
QApplication::processEvents();
// Now move mouse to col 0 (indent area — non-editable)
QPoint emptyPos = colToViewport(m_editor->scintilla(), kFirstDataLine, 0);
sendMouseMove(m_editor->scintilla()->viewport(), emptyPos);
QApplication::processEvents();
// Must be Arrow, NOT IBeam (QScintilla must not have leaked its cursor state)
QCOMPARE(viewportCursor(m_editor), Qt::ArrowCursor);
QVERIFY(!m_editor->isEditing());
}
// ── Test: CommandRow root class edits on line 0 ──
void testCommandRowRootClassEdits() {
m_editor->applyDocument(m_result);
// Set CommandRow text with root class (simulates controller.updateCommandRow)
m_editor->setCommandRowText(
QStringLiteral("source\u25BE 0xD87B5E5000 struct _PEB64 {"));
// RootClassName should be allowed on CommandRow (line 0)
bool ok = m_editor->beginInlineEdit(EditTarget::RootClassName, 0);
QVERIFY2(ok, "RootClassName edit should be allowed on CommandRow");
QVERIFY(m_editor->isEditing());
m_editor->cancelInlineEdit();
}
// ── Test: CommandRow root class name editable ──
void testCommandRowRootClassName() {
m_editor->applyDocument(m_result);
// Set CommandRow with root class
m_editor->setCommandRowText(
QStringLiteral("source\u25BE 0xD87B5E5000 struct _PEB64 {"));
// Line 0 is CommandRow
const LineMeta* lm = m_editor->metaForLine(0);
QVERIFY(lm);
QCOMPARE(lm->lineKind, LineKind::CommandRow);
// RootClassName should work
QVERIFY(m_editor->beginInlineEdit(EditTarget::RootClassName, 0));
QVERIFY(m_editor->isEditing());
m_editor->cancelInlineEdit();
m_editor->applyDocument(m_result);
}
// ── Test: root header/footer are suppressed (CommandRow replaces them) ──
void testRootFoldSuppressed() {
m_editor->applyDocument(m_result);
// Root struct header is completely suppressed from output.
// Line 0 = CommandRow, Line 1 = first field.
const LineMeta* lm2 = m_editor->metaForLine(kFirstDataLine);
QVERIFY(lm2);
QCOMPARE(lm2->lineKind, LineKind::Field);
// Verify no root header line exists in the output (footer may have isRootHeader for flush-left)
bool foundRootHeader = false;
for (int i = 0; i < m_result.meta.size(); i++) {
if (m_result.meta[i].isRootHeader && m_result.meta[i].lineKind == LineKind::Header) {
foundRootHeader = true;
break;
}
}
QVERIFY2(!foundRootHeader,
"Root header should be suppressed from compose output");
}
// ── Test: command row hover indicator survives refresh cycle ──
void testCommandRowHoverSurvivesRefresh() {
// IND_HOVER_SPAN = 11 (defined in editor.cpp, replicate for test)
constexpr int IND_HOVER_SPAN = 11;
m_editor->applyDocument(m_result);
// Set command row text (simulates controller.updateCommandRow)
QString cmdText = QStringLiteral(
"source\u25BE 0xD87B5E5000 struct _PEB64 {");
m_editor->setCommandRowText(cmdText);
QApplication::processEvents();
// Parse the source span on line 0
auto* sci = m_editor->scintilla();
int len = (int)sci->SendScintilla(
QsciScintillaBase::SCI_LINELENGTH, (unsigned long)0);
QVERIFY(len > 0);
QByteArray buf(len + 1, '\0');
sci->SendScintilla(QsciScintillaBase::SCI_GETLINE, (unsigned long)0,
(void*)buf.data());
QString lineText = QString::fromUtf8(buf.constData(), len);
while (lineText.endsWith('\n') || lineText.endsWith('\r'))
lineText.chop(1);
ColumnSpan srcSpan = commandRowSrcSpan(lineText);
QVERIFY2(srcSpan.valid, "Source span should be valid on command row");
// Programmatically move mouse to the source span
int hoverCol = srcSpan.start + 1;
QPoint hoverPos = colToViewport(sci, 0, hoverCol);
sendMouseMove(sci->viewport(), hoverPos);
QApplication::processEvents();
// Verify IND_HOVER_SPAN is set at the hover position
long pos = sci->SendScintilla(QsciScintillaBase::SCI_FINDCOLUMN,
(unsigned long)0, (long)hoverCol);
sci->SendScintilla(QsciScintillaBase::SCI_SETINDICATORCURRENT,
(unsigned long)IND_HOVER_SPAN);
int valBefore = (int)sci->SendScintilla(
QsciScintillaBase::SCI_INDICATORVALUEAT,
(unsigned long)IND_HOVER_SPAN, pos);
QVERIFY2(valBefore != 0,
"IND_HOVER_SPAN should be set on source span after hover");
// Verify cursor is PointingHand (Source target = clickable)
QCOMPARE(viewportCursor(m_editor), Qt::PointingHandCursor);
// ── Simulate a full refresh cycle (same order as controller.refresh) ──
ViewState vs = m_editor->saveViewState();
m_editor->applyDocument(m_result);
m_editor->restoreViewState(vs);
// Cursor must NOT have flipped to Arrow during applyDocument
// (applyHoverCursor is not called prematurely on composed text)
QCOMPARE(viewportCursor(m_editor), Qt::PointingHandCursor);
// updateCommandRow() — replaces line 0 text
m_editor->setCommandRowText(cmdText);
// applySelectionOverlays() — must run AFTER updateCommandRow
m_editor->applySelectionOverlay(QSet<uint64_t>());
QApplication::processEvents();
// Re-query the position (text was replaced, byte offset may have shifted)
long posAfter = sci->SendScintilla(QsciScintillaBase::SCI_FINDCOLUMN,
(unsigned long)0, (long)hoverCol);
int valAfter = (int)sci->SendScintilla(
QsciScintillaBase::SCI_INDICATORVALUEAT,
(unsigned long)IND_HOVER_SPAN, posAfter);
QVERIFY2(valAfter != 0,
"IND_HOVER_SPAN must survive refresh on command row "
"(hover should not flicker)");
// Cursor must still be PointingHand after full refresh cycle
QCOMPARE(viewportCursor(m_editor), Qt::PointingHandCursor);
m_editor->applyDocument(m_result);
}
// ── Test: command row hover survives multiple rapid refresh cycles ──
void testCommandRowHoverSurvivesRepeatedRefresh() {
constexpr int IND_HOVER_SPAN = 11;
m_editor->applyDocument(m_result);
QString cmdText = QStringLiteral(
"source\u25BE 0xD87B5E5000 struct _PEB64 {");
m_editor->setCommandRowText(cmdText);
QApplication::processEvents();
auto* sci = m_editor->scintilla();
int lineLen = (int)sci->SendScintilla(
QsciScintillaBase::SCI_LINELENGTH, (unsigned long)0);
QByteArray buf(lineLen + 1, '\0');
sci->SendScintilla(QsciScintillaBase::SCI_GETLINE, (unsigned long)0,
(void*)buf.data());
QString lineText = QString::fromUtf8(buf.constData(), lineLen);
while (lineText.endsWith('\n') || lineText.endsWith('\r'))
lineText.chop(1);
ColumnSpan srcSpan = commandRowSrcSpan(lineText);
QVERIFY(srcSpan.valid);
int hoverCol = srcSpan.start + 1;
// Move mouse into position
QPoint hoverPos = colToViewport(sci, 0, hoverCol);
sendMouseMove(sci->viewport(), hoverPos);
QApplication::processEvents();
// Simulate 5 rapid refresh cycles (like ~660ms timer x5)
for (int cycle = 0; cycle < 5; cycle++) {
ViewState vs = m_editor->saveViewState();
m_editor->applyDocument(m_result);
m_editor->restoreViewState(vs);
m_editor->setCommandRowText(cmdText);
m_editor->applySelectionOverlay(QSet<uint64_t>());
// Re-send mouse move each cycle (mouse is still there physically)
sendMouseMove(sci->viewport(), hoverPos);
QApplication::processEvents();
long pos = sci->SendScintilla(QsciScintillaBase::SCI_FINDCOLUMN,
(unsigned long)0, (long)hoverCol);
int val = (int)sci->SendScintilla(
QsciScintillaBase::SCI_INDICATORVALUEAT,
(unsigned long)IND_HOVER_SPAN, pos);
QVERIFY2(val != 0,
qPrintable(QString(
"IND_HOVER_SPAN lost on refresh cycle %1").arg(cycle)));
QVERIFY2(viewportCursor(m_editor) == Qt::PointingHandCursor,
qPrintable(QString(
"Cursor flipped away from PointingHand on cycle %1").arg(cycle)));
}
m_editor->applyDocument(m_result);
}
// ── Test: MenuBarStyle gives QMenu items generous click targets ──
// ── Test: M_ACCENT marker appears on selected rows ──
void testAccentMarkerOnSelectedRows() {
m_editor->applyDocument(m_result);
// Find a data line with a valid nodeId
uint64_t targetId = 0;
int targetLine = -1;
for (int i = kFirstDataLine; i < m_result.meta.size(); i++) {
const auto& lm = m_result.meta[i];
if (lm.nodeId != 0 && lm.nodeId != kCommandRowId
&& lm.lineKind == LineKind::Field) {
targetId = lm.nodeId;
targetLine = i;
break;
}
}
QVERIFY2(targetLine >= 0, "No data line found for accent test");
// Apply selection overlay with that node
QSet<uint64_t> selIds;
selIds.insert(targetId);
m_editor->applySelectionOverlay(selIds);
auto* sci = m_editor->scintilla();
// Direct test: add M_ACCENT manually and read it back
int directHandle = sci->markerAdd(targetLine, M_ACCENT);
int directMarkers = (int)sci->SendScintilla(
QsciScintillaBase::SCI_MARKERGET, (unsigned long)targetLine);
QVERIFY2(directMarkers & (1 << M_ACCENT),
qPrintable(QString("Direct markerAdd(M_ACCENT=%1) failed on line %2 (handle=%3, mask=0x%4)")
.arg(M_ACCENT).arg(targetLine).arg(directHandle).arg(directMarkers, 0, 16)));
sci->markerDelete(targetLine, M_ACCENT);
// Now test via applySelectionOverlay
m_editor->applySelectionOverlay(selIds);
// Verify M_SELECTED is set on the target line
int markers = (int)sci->SendScintilla(
QsciScintillaBase::SCI_MARKERGET, (unsigned long)targetLine);
QVERIFY2(markers & (1 << M_SELECTED),
qPrintable(QString("M_SELECTED not set on line %1 (mask=0x%2)")
.arg(targetLine).arg(markers, 0, 16)));
// Verify M_ACCENT is set on the target line
QVERIFY2(markers & (1 << M_ACCENT),
qPrintable(QString("M_ACCENT not set on line %1 (mask=0x%2)")
.arg(targetLine).arg(markers, 0, 16)));
// Verify a non-selected line does NOT have M_ACCENT
int otherLine = -1;
for (int i = kFirstDataLine; i < m_result.meta.size(); i++) {
const auto& lm = m_result.meta[i];
if (lm.nodeId != targetId && lm.nodeId != 0
&& lm.nodeId != kCommandRowId && lm.lineKind == LineKind::Field) {
otherLine = i;
break;
}
}
if (otherLine >= 0) {
int otherMarkers = (int)sci->SendScintilla(
QsciScintillaBase::SCI_MARKERGET, (unsigned long)otherLine);
QVERIFY2(!(otherMarkers & (1 << M_ACCENT)),
qPrintable(QString("M_ACCENT should NOT be set on non-selected line %1 (mask=0x%2)")
.arg(otherLine).arg(otherMarkers, 0, 16)));
}
// Clear selection and verify accent is removed
m_editor->applySelectionOverlay(QSet<uint64_t>());
markers = (int)sci->SendScintilla(
QsciScintillaBase::SCI_MARKERGET, (unsigned long)targetLine);
QVERIFY2(!(markers & (1 << M_ACCENT)),
qPrintable(QString("M_ACCENT should be cleared after deselection on line %1 (mask=0x%2)")
.arg(targetLine).arg(markers, 0, 16)));
}
void testMenuItemSizeIsAccessible() {
// Instantiate the same QProxyStyle used by the app (MenuBarStyle is
// defined in main.cpp — we replicate the logic here to test it)
class TestMenuStyle : public QProxyStyle {
public:
using QProxyStyle::QProxyStyle;
QSize sizeFromContents(ContentsType type, const QStyleOption* opt,
const QSize& sz, const QWidget* w) const override {
QSize s = QProxyStyle::sizeFromContents(type, opt, sz, w);
if (type == CT_MenuBarItem)
s.setHeight(s.height() + qRound(s.height() * 0.5));
if (type == CT_MenuItem)
s = QSize(s.width() + 24, s.height() + 4);
return s;
}
};
TestMenuStyle style;
QMenu menu;
auto* action = menu.addAction("Delete Node");
QStyleOptionMenuItem opt;
opt.initFrom(&menu);
opt.text = action->text();
QSize base = style.QProxyStyle::sizeFromContents(
QStyle::CT_MenuItem, &opt, QSize(80, 20), &menu);
QSize styled = style.sizeFromContents(
QStyle::CT_MenuItem, &opt, QSize(80, 20), &menu);
// Width must grow by at least 24px
QVERIFY2(styled.width() >= base.width() + 24,
qPrintable(QString("Menu item width %1 too narrow (base %2, need +24)")
.arg(styled.width()).arg(base.width())));
// Height must grow by at least 4px
QVERIFY2(styled.height() >= base.height() + 4,
qPrintable(QString("Menu item height %1 too short (base %2, need +4)")
.arg(styled.height()).arg(base.height())));
}
// ── Test: non-hex nodes don't show false heat coloring after offset shift ──
void testDeleteClearsHeatOnShiftedNodes() {
// Heat indicator constants (replicated from editor.cpp)
constexpr int IND_HEAT_COLD = 13;
constexpr int IND_HEAT_WARM = 17;
constexpr int IND_HEAT_HOT = 18;
// Build a small tree: root struct with mixed regular (non-hex) + hex fields
NodeTree tree;
tree.baseAddress = 0;
Node root;
root.kind = NodeKind::Struct;
root.structTypeName = "SmallStruct";
root.name = "s";
root.parentId = 0;
root.offset = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
// field0: UInt32 at offset 0 (4 bytes) — will be deleted
// field1: UInt32 at offset 4 (4 bytes) — regular type, will shift
// field2: Float at offset 8 (4 bytes) — regular type, will shift
// field3: Hex32 at offset 12 (4 bytes) — hex type, will shift
struct FieldDef { int off; NodeKind kind; const char* name; };
FieldDef defs[] = {
{ 0, NodeKind::UInt32, "count"},
{ 4, NodeKind::UInt32, "flags"},
{ 8, NodeKind::Float, "speed"},
{12, NodeKind::Hex32, "raw"},
};
QVector<uint64_t> fieldIds;
for (auto& d : defs) {
Node n;
n.kind = d.kind;
n.name = d.name;
n.parentId = rootId;
n.offset = d.off;
int idx = tree.addNode(n);
fieldIds.append(tree.nodes[idx].id);
}
// Create a provider with 16 bytes of recognizable data
QByteArray data(16, '\0');
uint32_t v0 = 42; memcpy(data.data() + 0, &v0, 4); // count=42
uint32_t v1 = 0xFF; memcpy(data.data() + 4, &v1, 4); // flags=255
float v2 = 3.14f; memcpy(data.data() + 8, &v2, 4); // speed=3.14
uint32_t v3 = 0xCAFE; memcpy(data.data() + 12, &v3, 4); // raw=0xCAFE
BufferProvider prov(data);
// Compose the initial document
ComposeResult result = compose(tree, prov);
// Inject heatLevel=2 (warm) on field1, field2, field3 — simulates
// heat accumulated before the delete
for (auto& lm : result.meta) {
for (int i = 1; i <= 3; i++) {
if (lm.nodeId == fieldIds[i])
lm.heatLevel = 2;
}
}
// Apply to editor — heat indicators should appear
m_editor->applyDocument(result);
QApplication::processEvents();
auto* sci = m_editor->scintilla();
// Helper: check if any heat indicator is set anywhere on a line
auto hasHeatOnLine = [&](int line) -> bool {
int lineLen = (int)sci->SendScintilla(
QsciScintillaBase::SCI_LINELENGTH, (unsigned long)line);
long lineStart = sci->SendScintilla(
QsciScintillaBase::SCI_POSITIONFROMLINE, (unsigned long)line);
for (long pos = lineStart; pos < lineStart + lineLen; pos++) {
for (int ind : { IND_HEAT_COLD, IND_HEAT_WARM, IND_HEAT_HOT }) {
int val = (int)sci->SendScintilla(
QsciScintillaBase::SCI_INDICATORVALUEAT,
(unsigned long)ind, pos);
if (val != 0) return true;
}
}
return false;
};
// Find lines for each shifted field
auto findFieldLine = [&](const ComposeResult& cr, uint64_t nodeId) -> int {
for (int i = 0; i < cr.meta.size(); i++) {
if (cr.meta[i].nodeId == nodeId && cr.meta[i].lineKind == LineKind::Field)
return i;
}
return -1;
};
int line1 = findFieldLine(result, fieldIds[1]);
int line2 = findFieldLine(result, fieldIds[2]);
int line3 = findFieldLine(result, fieldIds[3]);
QVERIFY(line1 >= 0);
QVERIFY(line2 >= 0);
QVERIFY(line3 >= 0);
// Verify heat indicators ARE present (UInt32, Float, and Hex32)
QVERIFY2(hasHeatOnLine(line1),
"Heat should be present on UInt32 'flags' before delete");
QVERIFY2(hasHeatOnLine(line2),
"Heat should be present on Float 'speed' before delete");
QVERIFY2(hasHeatOnLine(line3),
"Heat should be present on Hex32 'raw' before delete");
// ── Simulate delete of field0 (UInt32 'count' at offset 0) ──
int field0Idx = tree.indexOfId(fieldIds[0]);
QVERIFY(field0Idx >= 0);
tree.nodes.remove(field0Idx);
tree.invalidateIdCache();
// Shift remaining fields' offsets down by 4
for (int i = 1; i <= 3; i++) {
int fi = tree.indexOfId(fieldIds[i]);
if (fi >= 0) tree.nodes[fi].offset -= 4;
}
// Recompose — heatLevel defaults to 0 (simulates cleared history)
ComposeResult afterResult = compose(tree, prov);
// Apply the post-delete document to the editor
m_editor->applyDocument(afterResult);
QApplication::processEvents();
// Find new line positions
int newLine1 = findFieldLine(afterResult, fieldIds[1]);
int newLine2 = findFieldLine(afterResult, fieldIds[2]);
int newLine3 = findFieldLine(afterResult, fieldIds[3]);
QVERIFY(newLine1 >= 0);
QVERIFY(newLine2 >= 0);
QVERIFY(newLine3 >= 0);
// After applying heatLevel=0, NO heat indicators should appear
QVERIFY2(!hasHeatOnLine(newLine1),
"UInt32 'flags' should NOT show heat after offset shift "
"(old values are from wrong address)");
QVERIFY2(!hasHeatOnLine(newLine2),
"Float 'speed' should NOT show heat after offset shift "
"(old values are from wrong address)");
QVERIFY2(!hasHeatOnLine(newLine3),
"Hex32 'raw' should NOT show heat after offset shift "
"(old values are from wrong address)");
// Restore original document
m_editor->applyDocument(m_result);
}
void testMenuHoverRendersAmberText() {
// Replicate MenuBarStyle with drawControl hover override
class TestMenuStyle : public QProxyStyle {
public:
using QProxyStyle::QProxyStyle;
QSize sizeFromContents(ContentsType type, const QStyleOption* opt,
const QSize& sz, const QWidget* w) const override {
QSize s = QProxyStyle::sizeFromContents(type, opt, sz, w);
if (type == CT_MenuBarItem)
s.setHeight(s.height() + qRound(s.height() * 0.5));
if (type == CT_MenuItem)
s = QSize(s.width() + 24, s.height() + 4);
return s;
}
void drawPrimitive(PrimitiveElement elem, const QStyleOption* opt,
QPainter* p, const QWidget* w) const override {
if (elem == PE_FrameMenu) return;
QProxyStyle::drawPrimitive(elem, opt, p, w);
}
void drawControl(ControlElement element, const QStyleOption* opt,
QPainter* p, const QWidget* w) const override {
if (element == CE_MenuItem || element == CE_MenuBarItem) {
if (auto* mi = qstyleoption_cast<const QStyleOptionMenuItem*>(opt)) {
if ((mi->state & State_Selected)
&& mi->menuItemType != QStyleOptionMenuItem::Separator) {
QStyleOptionMenuItem patched = *mi;
patched.palette.setColor(QPalette::Highlight,
mi->palette.color(QPalette::Mid));
patched.palette.setColor(QPalette::HighlightedText,
mi->palette.color(QPalette::Link));
QProxyStyle::drawControl(element, &patched, p, w);
return;
}
}
}
QProxyStyle::drawControl(element, opt, p, w);
}
};
// Install our style as the app style (same as main.cpp does)
qApp->setStyle(new TestMenuStyle("Fusion"));
// Set app palette matching applyGlobalTheme for Reclass Dark
QPalette pal;
pal.setColor(QPalette::Window, QColor("#1e1e1e"));
pal.setColor(QPalette::WindowText, QColor("#d4d4d4"));
pal.setColor(QPalette::Base, QColor("#252526"));
pal.setColor(QPalette::AlternateBase, QColor("#2a2d2e"));
pal.setColor(QPalette::Text, QColor("#d4d4d4"));
pal.setColor(QPalette::Button, QColor("#333333"));
pal.setColor(QPalette::ButtonText, QColor("#d4d4d4"));
pal.setColor(QPalette::Highlight, QColor("#2b2b2b"));
pal.setColor(QPalette::HighlightedText, QColor("#E6B450"));
pal.setColor(QPalette::Mid, QColor("#3c3c3c"));
pal.setColor(QPalette::Dark, QColor("#1e1e1e"));
pal.setColor(QPalette::Light, QColor("#505050"));
pal.setColor(QPalette::Link, QColor("#E6B450"));
qApp->setPalette(pal);
// Build and show a real QMenu
QMenu menu;
menu.addAction("First Item");
menu.addAction("Second Item");
menu.addAction("Third Item");
menu.popup(QPoint(100, 100));
QVERIFY(QTest::qWaitForWindowExposed(&menu));
QApplication::processEvents();
// ── Deliver real mouse events to trigger hover on second item ──
QList<QAction*> actions = menu.actions();
QRect itemRect = menu.actionGeometry(actions[1]);
QPoint localCenter = itemRect.center();
// Enter event — tells QMenu the mouse is inside
QEvent enter(QEvent::Enter);
QApplication::sendEvent(&menu, &enter);
QApplication::processEvents();
// MouseMove to the second item — triggers hover/select
QMouseEvent move(QEvent::MouseMove, QPointF(localCenter),
menu.mapToGlobal(localCenter),
Qt::NoButton, Qt::NoButton, Qt::NoModifier);
QApplication::sendEvent(&menu, &move);
QApplication::processEvents();
QTest::qWait(50); // let repaint settle
// Verify QMenu internally considers the action hovered
QVERIFY2(menu.activeAction() == actions[1],
"QMenu did not set activeAction after mouse move — "
"hover event delivery failed");
// ── Capture what's actually on screen ──
QScreen* screen = QGuiApplication::primaryScreen();
QVERIFY(screen);
QPixmap grab = screen->grabWindow(menu.winId());
QImage img = grab.toImage().convertToFormat(QImage::Format_ARGB32);
// Crop to just the hovered item rect
QImage itemImg = img.copy(itemRect);
// Scan hovered item for amber pixels (E6B450 = R:230 G:180 B:80)
int amberPixels = 0;
int totalPixels = itemImg.width() * itemImg.height();
for (int y = 0; y < itemImg.height(); ++y) {
for (int x = 0; x < itemImg.width(); ++x) {
QColor c = itemImg.pixelColor(x, y);
if (c.red() > 180 && c.green() > 140 && c.blue() < 100)
++amberPixels;
}
}
// Always save screenshots so we can visually inspect
img.save("menu_hover_full.png");
itemImg.save("menu_hover_item.png");
menu.close();
QVERIFY2(amberPixels > 10,
qPrintable(QString("Expected amber text pixels in hovered item, "
"found %1 / %2 total (see menu_hover_full.png, menu_hover_item.png)")
.arg(amberPixels).arg(totalPixels)));
}
void testStructPreviewPopupOnCollapsedTypedPointer() {
// Build a small tree: root struct with a typed Pointer64 → target struct
NodeTree tree;
tree.baseAddress = 0;
Node root;
root.kind = NodeKind::Struct;
root.structTypeName = "TestRoot";
root.name = "Root";
root.parentId = 0;
root.offset = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
// Target struct with some fields
Node target;
target.kind = NodeKind::Struct;
target.structTypeName = "TargetStruct";
target.name = "TargetStruct";
target.parentId = 0;
target.offset = 0;
int ti = tree.addNode(target);
uint64_t targetId = tree.nodes[ti].id;
// Add fields to the target struct
{
Node f; f.parentId = targetId;
f.kind = NodeKind::UInt64; f.name = "FieldA"; f.offset = 0;
tree.addNode(f);
f.kind = NodeKind::UInt64; f.name = "FieldB"; f.offset = 8;
tree.addNode(f);
f.kind = NodeKind::UInt32; f.name = "FieldC"; f.offset = 16;
tree.addNode(f);
}
// Add a Pointer64 node that references the target struct, collapsed
Node ptr;
ptr.kind = NodeKind::Pointer64;
ptr.name = "pTarget";
ptr.parentId = rootId;
ptr.offset = 0;
ptr.refId = targetId;
ptr.collapsed = true;
tree.addNode(ptr);
// Provider: 8 bytes at offset 0 holding a pointer value
QByteArray data(64, '\0');
uint64_t ptrVal = 0x00007FFE12340000ULL;
memcpy(data.data(), &ptrVal, 8);
BufferProvider prov(data, "test_struct_preview");
ComposeResult cr = compose(tree, prov);
m_editor->applyDocument(cr);
m_editor->setProviderRef(&prov, nullptr, &tree);
QApplication::processEvents();
// Find the pointer line (should be a Pointer64 with foldCollapsed=true)
int ptrLine = -1;
for (int i = 0; i < cr.meta.size(); ++i) {
if (cr.meta[i].nodeKind == NodeKind::Pointer64
&& cr.meta[i].foldCollapsed) {
ptrLine = i;
break;
}
}
QVERIFY2(ptrLine >= 0, "Could not find collapsed Pointer64 line in compose output");
// Simulate hover over the value column of the pointer line
const LineMeta& lm = cr.meta[ptrLine];
QString lineText;
{
long len = m_editor->scintilla()->SendScintilla(
QsciScintillaBase::SCI_LINELENGTH, (unsigned long)ptrLine);
QByteArray buf(len + 1, '\0');
m_editor->scintilla()->SendScintilla(
QsciScintillaBase::SCI_GETLINE, (uintptr_t)ptrLine, static_cast<const char*>(buf.data()));
lineText = QString::fromUtf8(buf.left(len));
}
ColumnSpan vs = m_editor->valueSpan(lm, lineText.size(),
lm.effectiveTypeW, lm.effectiveNameW);
QVERIFY2(vs.valid, "Value span for pointer line is not valid");
int hoverCol = (vs.start + vs.end) / 2; // middle of value span
QPoint vp = colToViewport(m_editor->scintilla(), ptrLine, hoverCol);
sendMouseMove(m_editor->scintilla()->viewport(), vp);
QApplication::processEvents();
// Verify struct preview popup is shown
QVERIFY2(m_editor->structPreviewPopup() != nullptr,
"Struct preview popup was not created");
QVERIFY2(m_editor->structPreviewPopup()->isVisible(),
"Struct preview popup is not visible");
// Restore original document for other tests
m_editor->setProviderRef(nullptr, nullptr, nullptr);
m_editor->applyDocument(m_result);
}
void testStructPreviewPopupNotShownWhenExpanded() {
// Same tree but pointer is NOT collapsed — popup should not show
NodeTree tree;
tree.baseAddress = 0;
Node root;
root.kind = NodeKind::Struct;
root.structTypeName = "TestRoot";
root.name = "Root";
root.parentId = 0;
root.offset = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
Node target;
target.kind = NodeKind::Struct;
target.structTypeName = "TargetStruct";
target.name = "TargetStruct";
target.parentId = 0;
target.offset = 0;
int ti = tree.addNode(target);
uint64_t targetId = tree.nodes[ti].id;
{
Node f; f.parentId = targetId;
f.kind = NodeKind::UInt64; f.name = "FieldA"; f.offset = 0;
tree.addNode(f);
f.kind = NodeKind::UInt64; f.name = "FieldB"; f.offset = 8;
tree.addNode(f);
}
Node ptr;
ptr.kind = NodeKind::Pointer64;
ptr.name = "pTarget";
ptr.parentId = rootId;
ptr.offset = 0;
ptr.refId = targetId;
ptr.collapsed = false; // expanded
tree.addNode(ptr);
QByteArray data(64, '\0');
uint64_t ptrVal = 0x00007FFE12340000ULL;
memcpy(data.data(), &ptrVal, 8);
BufferProvider prov(data, "test_struct_preview_expanded");
ComposeResult cr = compose(tree, prov);
m_editor->applyDocument(cr);
m_editor->setProviderRef(&prov, nullptr, &tree);
QApplication::processEvents();
// Find the pointer line (should be Pointer64 and NOT collapsed)
int ptrLine = -1;
for (int i = 0; i < cr.meta.size(); ++i) {
if (cr.meta[i].nodeKind == NodeKind::Pointer64) {
ptrLine = i;
break;
}
}
QVERIFY2(ptrLine >= 0, "Could not find Pointer64 line in compose output");
// Hover at a middle column on the pointer line — expanded pointer header
// may not have a standard value span, but we just need to verify no popup
int hoverCol = 40; // somewhere in the middle of the line
QPoint vp = colToViewport(m_editor->scintilla(), ptrLine, hoverCol);
sendMouseMove(m_editor->scintilla()->viewport(), vp);
QApplication::processEvents();
// Struct preview popup should NOT be visible (pointer is expanded)
bool popupVisible = m_editor->structPreviewPopup()
&& m_editor->structPreviewPopup()->isVisible();
QVERIFY2(!popupVisible,
"Struct preview popup should not appear for expanded pointer");
// Restore
m_editor->setProviderRef(nullptr, nullptr, nullptr);
m_editor->applyDocument(m_result);
}
// ── Test: expanded pointer renders child fields from buffer ──
void testPointerExpansionRendersChildren() {
PtrDemo d = makePtrDemo(/*collapsed=*/false);
ComposeResult cr = compose(d.tree, d.prov);
m_editor->applyDocument(cr);
QApplication::processEvents();
// Find the pointer header line
int ptrHeaderLine = -1;
for (int i = 0; i < cr.meta.size(); ++i) {
if (cr.meta[i].nodeKind == NodeKind::Pointer64
&& cr.meta[i].foldHead && !cr.meta[i].foldCollapsed) {
ptrHeaderLine = i;
break;
}
}
QVERIFY2(ptrHeaderLine >= 0, "Should have an expanded Pointer64 header");
QCOMPARE(cr.meta[ptrHeaderLine].lineKind, LineKind::Header);
// Find expanded child fields (x, y, z at depth = header depth + 1)
int headerDepth = cr.meta[ptrHeaderLine].depth;
int childFieldCount = 0;
for (int i = ptrHeaderLine + 1; i < cr.meta.size(); ++i) {
const LineMeta& lm = cr.meta[i];
if (lm.depth == headerDepth + 1 && lm.lineKind == LineKind::Field)
childFieldCount++;
if (lm.lineKind == LineKind::Footer && lm.nodeKind == NodeKind::Pointer64)
break; // reached pointer footer
}
QCOMPARE(childFieldCount, 3); // x, y, z
// Find the pointer footer line
int ptrFooterLine = -1;
for (int i = ptrHeaderLine + 1; i < cr.meta.size(); ++i) {
if (cr.meta[i].lineKind == LineKind::Footer
&& cr.meta[i].nodeKind == NodeKind::Pointer64) {
ptrFooterLine = i;
break;
}
}
QVERIFY2(ptrFooterLine > ptrHeaderLine, "Should have a pointer footer after header");
// Verify the composed text contains the child field values
// UInt32 displays as hex (e.g. 100 → "0x00000064"), Float as decimal
QStringList lines = cr.text.split('\n');
bool foundX = false, foundY = false, foundZ = false;
for (const QString& line : lines) {
if (line.contains("0x64") && line.contains("x")) foundX = true; // 100 = 0x64
if (line.contains("0xc8") && line.contains("y")) foundY = true; // 200 = 0xc8
if (line.contains("3.14") && line.contains("z")) foundZ = true;
}
QVERIFY2(foundX, "Child field 'x' with value 0x64 should appear in output");
QVERIFY2(foundY, "Child field 'y' with value 0xc8 should appear in output");
QVERIFY2(foundZ, "Child field 'z' with value 3.14 should appear in output");
// Verify the pointer type name appears
QVERIFY2(cr.text.contains("ChildData*"),
"Pointer type 'ChildData*' should appear in output");
// Editor should have rendered all lines
int editorLineCount = m_editor->scintilla()->lines();
QVERIFY2(editorLineCount >= cr.meta.size(),
qPrintable(QString("Editor has %1 lines but compose has %2 meta entries")
.arg(editorLineCount).arg(cr.meta.size())));
m_editor->applyDocument(m_result);
}
// ── Test: collapsed pointer hides child fields ──
void testPointerCollapsedHidesChildren() {
PtrDemo expanded = makePtrDemo(/*collapsed=*/false);
ComposeResult crExpanded = compose(expanded.tree, expanded.prov);
PtrDemo collapsed = makePtrDemo(/*collapsed=*/true);
ComposeResult crCollapsed = compose(collapsed.tree, collapsed.prov);
// Collapsed should have fewer lines (no child fields, no pointer footer)
QVERIFY2(crCollapsed.meta.size() < crExpanded.meta.size(),
qPrintable(QString("Collapsed (%1 lines) should be smaller than expanded (%2)")
.arg(crCollapsed.meta.size()).arg(crExpanded.meta.size())));
// The pointer line should be a Field (not Header) with foldCollapsed=true
bool foundCollapsedPtr = false;
for (const LineMeta& lm : crCollapsed.meta) {
if (lm.nodeKind == NodeKind::Pointer64 && lm.foldHead) {
QVERIFY(lm.foldCollapsed);
QCOMPARE(lm.lineKind, LineKind::Field);
foundCollapsedPtr = true;
break;
}
}
QVERIFY2(foundCollapsedPtr, "Should have a collapsed Pointer64 fold head");
// No child fields from ChildData should appear in the main struct section
bool foundChildField = false;
for (const LineMeta& lm : crCollapsed.meta) {
if (lm.lineKind == LineKind::Footer && lm.nodeKind == NodeKind::Pointer64) {
foundChildField = true; // pointer footer exists = children visible
break;
}
}
QVERIFY2(!foundChildField,
"Collapsed pointer should not have a pointer footer (no children)");
// Apply collapsed to editor
m_editor->applyDocument(crCollapsed);
QApplication::processEvents();
int collapsedLines = m_editor->scintilla()->lines();
m_editor->applyDocument(crExpanded);
QApplication::processEvents();
int expandedLines = m_editor->scintilla()->lines();
QVERIFY2(collapsedLines < expandedLines,
qPrintable(QString("Collapsed (%1 editor lines) should be fewer than expanded (%2)")
.arg(collapsedLines).arg(expandedLines)));
m_editor->applyDocument(m_result);
}
// ── Test: null pointer still shows template fields (via NullProvider) ──
void testPointerNullShowsTemplate() {
PtrDemo d = makePtrDemo(/*collapsed=*/false, /*nullPtr=*/true);
ComposeResult cr = compose(d.tree, d.prov);
m_editor->applyDocument(cr);
QApplication::processEvents();
// Even with null pointer, expanded pointer should show template children
int ptrHeaderLine = -1;
for (int i = 0; i < cr.meta.size(); ++i) {
if (cr.meta[i].nodeKind == NodeKind::Pointer64
&& cr.meta[i].foldHead && !cr.meta[i].foldCollapsed) {
ptrHeaderLine = i;
break;
}
}
QVERIFY2(ptrHeaderLine >= 0,
"Null pointer should still produce an expanded header");
// Should have child field lines (template from NullProvider shows zeros)
int headerDepth = cr.meta[ptrHeaderLine].depth;
int childFieldCount = 0;
for (int i = ptrHeaderLine + 1; i < cr.meta.size(); ++i) {
const LineMeta& lm = cr.meta[i];
if (lm.depth == headerDepth + 1 && lm.lineKind == LineKind::Field)
childFieldCount++;
if (lm.lineKind == LineKind::Footer && lm.nodeKind == NodeKind::Pointer64)
break;
}
QCOMPARE(childFieldCount, 3); // x, y, z template still rendered
// Verify ChildData* appears in output
QVERIFY2(cr.text.contains("ChildData*"),
"Null pointer should still show 'ChildData*' type");
m_editor->applyDocument(m_result);
}
// ── Test: nested pointer chain renders multiple expansion levels ──
void testPointerChainExpansion() {
NodeTree tree;
tree.baseAddress = 0;
// Root struct
Node root;
root.kind = NodeKind::Struct;
root.structTypeName = "Chain";
root.name = "chain";
root.parentId = 0;
root.collapsed = false;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
// Inner struct (innermost target)
Node inner;
inner.kind = NodeKind::Struct;
inner.structTypeName = "Inner";
inner.name = "Inner";
inner.parentId = 0;
inner.offset = 300;
inner.collapsed = false;
int ii = tree.addNode(inner);
uint64_t innerId = tree.nodes[ii].id;
{
Node f;
f.kind = NodeKind::UInt32; f.name = "value";
f.parentId = innerId; f.offset = 0;
tree.addNode(f);
}
// Outer struct (contains pointer to Inner)
Node outer;
outer.kind = NodeKind::Struct;
outer.structTypeName = "Outer";
outer.name = "Outer";
outer.parentId = 0;
outer.offset = 200;
outer.collapsed = false;
int oi = tree.addNode(outer);
uint64_t outerId = tree.nodes[oi].id;
{
Node f;
f.kind = NodeKind::UInt32; f.name = "tag";
f.parentId = outerId; f.offset = 0;
tree.addNode(f);
Node p;
p.kind = NodeKind::Pointer64; p.name = "pInner";
p.parentId = outerId; p.offset = 8;
p.refId = innerId;
p.collapsed = false;
tree.addNode(p);
}
// Root pointer to Outer
{
Node p;
p.kind = NodeKind::Pointer64; p.name = "pOuter";
p.parentId = rootId; p.offset = 0;
p.refId = outerId;
p.collapsed = false;
tree.addNode(p);
}
// Buffer: pOuter at 0 → 32, pInner at 32+8=40 → 64, value at 64 = 999
QByteArray data(128, '\0');
uint64_t pOuter = 32; memcpy(data.data() + 0, &pOuter, 8);
uint64_t pInner = 64; memcpy(data.data() + 40, &pInner, 8);
uint32_t tag = 0xAB; memcpy(data.data() + 32, &tag, 4);
uint32_t val = 999; memcpy(data.data() + 64, &val, 4);
BufferProvider prov(data, "chain_demo");
ComposeResult cr = compose(tree, prov);
m_editor->applyDocument(cr);
QApplication::processEvents();
// Both Outer* and Inner* should appear
QVERIFY2(cr.text.contains("Outer*"), "Should display 'Outer*' pointer type");
QVERIFY2(cr.text.contains("Inner*"), "Should display 'Inner*' pointer type");
// Count pointer fold heads — should have at least 2 (pOuter + pInner)
int ptrFoldHeads = 0;
int maxDepth = 0;
for (const LineMeta& lm : cr.meta) {
if (lm.foldHead && lm.nodeKind == NodeKind::Pointer64)
ptrFoldHeads++;
if (lm.depth > maxDepth) maxDepth = lm.depth;
}
QVERIFY2(ptrFoldHeads >= 2,
qPrintable(QString("Expected >=2 pointer fold heads, got %1")
.arg(ptrFoldHeads)));
// Depth should reach at least 3 (root=0, pOuter children=1..2, pInner children=2..3)
QVERIFY2(maxDepth >= 3,
qPrintable(QString("Expected max depth >= 3 for chain, got %1")
.arg(maxDepth)));
// Verify innermost value (999 = 0x3e7) appears in the output
QVERIFY2(cr.text.contains("0x3e7"),
"Innermost field 'value = 0x3e7' should appear in chain expansion");
m_editor->applyDocument(m_result);
}
// ── Test: status bar view toggle buttons (pixel-level) ──
void testStatusBarViewToggleButtons() {
// Mirror the production ViewTabButton from main.cpp
static constexpr int kAccentH = 2;
static constexpr int kPadLR = 12;
static constexpr int kPadBot = 4;
class VTB : public QPushButton {
public:
QColor colBg, colBgChecked, colBgHover, colBgPressed;
QColor colText, colTextMuted, colAccent;
explicit VTB(const QString& t, QWidget* p = nullptr) : QPushButton(t, p) {
setCheckable(true); setFlat(true); setContentsMargins(0,0,0,0);
setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Ignored);
}
QSize sizeHint() const override {
QFontMetrics fm(font());
return QSize(fm.horizontalAdvance(text()) + 2*kPadLR,
fm.height() + kAccentH + kPadBot);
}
protected:
void paintEvent(QPaintEvent*) override {
QPainter p(this);
QColor bg = colBg;
if (isDown()) bg = colBgPressed;
else if (underMouse()) bg = colBgHover;
else if (isChecked()) bg = colBgChecked;
p.fillRect(rect(), bg);
if (isChecked())
p.fillRect(0, 0, width(), kAccentH, colAccent);
p.setPen(isChecked() || underMouse() || isDown() ? colText : colTextMuted);
p.setFont(font());
QRect tr(kPadLR, kAccentH, width()-2*kPadLR, height()-kAccentH);
p.drawText(tr, Qt::AlignVCenter|Qt::AlignLeft, text());
}
void enterEvent(QEnterEvent*) override { update(); }
void leaveEvent(QEvent*) override { update(); }
};
QColor bg(30,30,30), bgAlt(45,45,48), hover(62,62,66);
QColor text(212,212,212), textMuted(128,128,128);
QColor accent("#b180d7");
QColor pressed = hover.darker(130);
auto setColors = [&](VTB* b) {
b->colBg = bg; b->colBgChecked = bgAlt; b->colBgHover = hover;
b->colBgPressed = pressed; b->colText = text;
b->colTextMuted = textMuted; b->colAccent = accent;
};
// Borderless status bar with manual layout (mirrors production FlatStatusBar)
class FSB : public QStatusBar {
public:
QWidget* tabRow = nullptr;
QLabel* label = nullptr;
FSB() { setSizeGripEnabled(false); }
protected:
void paintEvent(QPaintEvent*) override {
QPainter p(this); p.fillRect(rect(), palette().window());
}
void resizeEvent(QResizeEvent* e) override {
QStatusBar::resizeEvent(e);
doLayout();
}
void showEvent(QShowEvent* e) override {
QStatusBar::showEvent(e);
doLayout();
}
private:
void doLayout() {
if (!tabRow || !label) return;
int h = height(), tw = tabRow->sizeHint().width();
tabRow->setGeometry(0, 0, tw, h);
label->setGeometry(tw, 0, width() - tw, h);
}
};
QMainWindow win;
win.resize(600, 400);
QPalette pal; pal.setColor(QPalette::Window, bg);
win.setPalette(pal);
auto* sb = new FSB;
win.setStatusBar(sb);
sb->setPalette(pal);
sb->setAutoFillBackground(true);
if (win.layout()) {
win.layout()->setSpacing(0);
win.layout()->setContentsMargins(0,0,0,0);
}
auto* btnGroup = new QButtonGroup(&win);
btnGroup->setExclusive(true);
auto* btnR = new VTB("Reclass");
auto* btnC = new VTB("C/C++");
setColors(btnR); setColors(btnC);
btnR->setChecked(true);
btnGroup->addButton(btnR, 0);
btnGroup->addButton(btnC, 1);
auto* tabRow = new QWidget(sb);
auto* tabLay = new QHBoxLayout(tabRow);
tabLay->setContentsMargins(0,0,0,0);
tabLay->setSpacing(0);
tabLay->addWidget(btnR);
tabLay->addWidget(btnC);
auto* lbl = new QLabel("Ready", sb);
lbl->setContentsMargins(10,0,0,0);
sb->tabRow = tabRow;
sb->label = lbl;
win.show();
QVERIFY(QTest::qWaitForWindowExposed(&win));
QTest::qWait(100);
// ── Toggle logic ──
QVERIFY(btnR->isChecked());
QVERIFY(!btnC->isChecked());
QTest::mouseClick(btnC, Qt::LeftButton);
QVERIFY(btnC->isChecked());
QVERIFY(!btnR->isChecked());
QTest::mouseClick(btnR, Qt::LeftButton);
QVERIFY(btnR->isChecked());
QTest::qWait(50);
// ── Pixel: accent line on checked button at rows 0..(kAccentH-1) ──
QImage imgR = btnR->grab().toImage().convertToFormat(QImage::Format_ARGB32);
QVERIFY(imgR.height() >= kAccentH + 4);
// Every pixel in the top kAccentH rows (middle 80% width) must be accent
int x0 = imgR.width() / 10, x1 = imgR.width() * 9 / 10;
for (int y = 0; y < kAccentH; y++) {
for (int x = x0; x < x1; x++) {
QColor c(imgR.pixel(x, y));
QVERIFY2(qAbs(c.red() - accent.red()) < 10
&& qAbs(c.green() - accent.green()) < 10
&& qAbs(c.blue() - accent.blue()) < 10,
qPrintable(QString("Checked btn pixel(%1,%2)=%3 expected accent %4")
.arg(x).arg(y).arg(c.name(), accent.name())));
}
}
// Mid-height row must NOT be accent (accent doesn't bleed into body)
{
int midY = imgR.height() / 2;
QColor c(imgR.pixel(imgR.width()/2, midY));
QVERIFY2(qAbs(c.red() - accent.red()) > 15
|| qAbs(c.green() - accent.green()) > 15
|| qAbs(c.blue() - accent.blue()) > 15,
qPrintable(QString("Row %1 should be background, not accent: %2")
.arg(midY).arg(c.name())));
}
// ── Pixel: unchecked button has NO accent line ──
QImage imgC = btnC->grab().toImage().convertToFormat(QImage::Format_ARGB32);
for (int y = 0; y < kAccentH; y++) {
QColor c(imgC.pixel(imgC.width()/2, y));
QVERIFY2(qAbs(c.red() - accent.red()) > 15
|| qAbs(c.green() - accent.green()) > 15
|| qAbs(c.blue() - accent.blue()) > 15,
qPrintable(QString("Unchecked btn row %1 has accent: %2")
.arg(y).arg(c.name())));
}
// ── Pixel: zero gap between the two buttons ──
// Map to their shared parent (the tabRow container)
QWidget* container = btnR->parentWidget();
int rRight = btnR->mapTo(container, QPoint(btnR->width(), 0)).x();
int cLeft = btnC->mapTo(container, QPoint(0, 0)).x();
QVERIFY2(rRight == cLeft,
qPrintable(QString("Gap between buttons: btnR right=%1 btnC left=%2 gap=%3")
.arg(rRight).arg(cLeft).arg(cLeft - rRight)));
// ── Pressed color is darker than hover ──
QVERIFY2(pressed.lightness() < hover.lightness(),
qPrintable(QString("Pressed %1 should be darker than hover %2")
.arg(pressed.name(), hover.name())));
// ── Button starts at x=0 in status bar (no left padding) ──
QPoint btnTopLeft = tabRow->mapTo(sb, QPoint(0, 0));
QVERIFY2(btnTopLeft.x() == 0,
qPrintable(QString("Tab row left margin: x=%1, expected 0").arg(btnTopLeft.x())));
// ── Button starts at y=0 in status bar (no top padding) ──
QVERIFY2(btnTopLeft.y() == 0,
qPrintable(QString("Tab row top margin: y=%1, expected 0").arg(btnTopLeft.y())));
// ── Button takes full status bar height ──
QVERIFY2(btnR->height() == sb->height(),
qPrintable(QString("Button height=%1 sb height=%2")
.arg(btnR->height()).arg(sb->height())));
// ── Accent at y=0 in status bar pixel coordinates (grab status bar) ──
QImage sbImg = sb->grab().toImage().convertToFormat(QImage::Format_ARGB32);
{
QColor c(sbImg.pixel(btnR->width()/2, 0));
QVERIFY2(qAbs(c.red() - accent.red()) < 10
&& qAbs(c.green() - accent.green()) < 10
&& qAbs(c.blue() - accent.blue()) < 10,
qPrintable(QString("Status bar pixel(x,%1,0)=%2 expected accent %3")
.arg(btnR->width()/2).arg(c.name(), accent.name())));
}
qDebug() << QString("ViewTabButton: accent=%1 btnH=%2 sbH=%3 gap=%4 leftX=%5 topY=%6")
.arg(accent.name()).arg(btnR->height()).arg(sb->height())
.arg(cLeft - rRight).arg(btnTopLeft.x()).arg(btnTopLeft.y());
}
// ── Test: resize grip dots are equidistant from right and bottom window edges ──
// The grip is a direct child of the window positioned via move(), not inside
// the status bar layout. This test verifies the dot placement is symmetric
// regardless of font, and runs the check at two different font sizes to prove
// font independence.
// ── Test: horizontal scrollbar after long name rename ──
void testHScrollResetAfterNameShrink() {
// Use a dedicated narrow editor so content easily overflows the viewport
auto* editor = new RcxEditor();
editor->resize(200, 300);
editor->show();
QVERIFY(QTest::qWaitForWindowExposed(editor));
auto* sci = editor->scintilla();
auto* hbar = sci->horizontalScrollBar();
auto makeTree = [](const QString& fieldName) {
NodeTree tree;
tree.baseAddress = 0;
Node root;
root.kind = NodeKind::Struct;
root.structTypeName = "MyStruct";
root.name = "s";
root.parentId = 0;
root.offset = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
Node f;
f.kind = NodeKind::Int32;
f.name = fieldName;
f.parentId = rootId;
f.offset = 0;
tree.addNode(f);
return tree;
};
BufferProvider prov(QByteArray(64, '\0'));
// ── Step 1: long name → wide content, scrollbar must appear ──
QString longName = QString(120, QChar('W'));
{
NodeTree tree = makeTree(longName);
ComposeResult cr = compose(tree, prov);
editor->applyDocument(cr);
QApplication::processEvents();
QTest::qWait(50);
}
int scrollW1 = (int)sci->SendScintilla(QsciScintillaBase::SCI_GETSCROLLWIDTH);
int viewW = sci->viewport()->width();
qDebug() << QString("Long name: scrollW=%1 vpW=%2 hbar.visible=%3 "
"hbar.max=%4 hbar.value=%5")
.arg(scrollW1).arg(viewW)
.arg(hbar->isVisible())
.arg(hbar->maximum()).arg(hbar->value());
QVERIFY2(scrollW1 > viewW,
qPrintable(QString("scrollW=%1 should exceed vpW=%2")
.arg(scrollW1).arg(viewW)));
// Scrollbar must be visible when content overflows
QVERIFY2(hbar->isVisible(),
"Horizontal scrollbar should be visible when content overflows");
QVERIFY2(hbar->maximum() > 0,
qPrintable(QString("Scrollbar max should be >0, got %1")
.arg(hbar->maximum())));
// Simulate user scrolled right
sci->SendScintilla(QsciScintillaBase::SCI_SETXOFFSET, (unsigned long)(scrollW1 / 2));
QApplication::processEvents();
QTest::qWait(20);
int xOff1 = (int)sci->SendScintilla(QsciScintillaBase::SCI_GETXOFFSET);
QVERIFY2(xOff1 > 0, "X offset should be non-zero after scrolling right");
// ── Step 2: short name → narrower content ──
{
NodeTree tree = makeTree("x");
ComposeResult cr = compose(tree, prov);
editor->applyDocument(cr);
QApplication::processEvents();
QTest::qWait(50);
}
int scrollW2 = (int)sci->SendScintilla(QsciScintillaBase::SCI_GETSCROLLWIDTH);
int xOff2 = (int)sci->SendScintilla(QsciScintillaBase::SCI_GETXOFFSET);
qDebug() << QString("Short name: scrollW=%1 xOff=%2 vpW=%3 hbar.visible=%4 "
"hbar.max=%5 hbar.value=%6")
.arg(scrollW2).arg(xOff2).arg(viewW)
.arg(hbar->isVisible())
.arg(hbar->maximum()).arg(hbar->value());
// Scroll width should have shrunk
QVERIFY2(scrollW2 < scrollW1,
qPrintable(QString("scrollW should shrink: was %1, now %2")
.arg(scrollW1).arg(scrollW2)));
// X offset must be clamped to max(0, scrollW - viewportW)
int maxValidXOff = qMax(0, scrollW2 - viewW);
QVERIFY2(xOff2 <= maxValidXOff,
qPrintable(QString("xOffset=%1 exceeds max valid=%2 (scrollW=%3 vpW=%4)")
.arg(xOff2).arg(maxValidXOff).arg(scrollW2).arg(viewW)));
// If content fits viewport entirely, offset must be 0
if (scrollW2 <= viewW) {
QCOMPARE(xOff2, 0);
}
// If content still overflows, scrollbar must still be visible
if (scrollW2 > viewW) {
QVERIFY2(hbar->isVisible(),
"Scrollbar should remain visible when content still overflows");
}
// ── Step 3: apply long name again → scrollbar must reappear ──
{
NodeTree tree = makeTree(longName);
ComposeResult cr = compose(tree, prov);
editor->applyDocument(cr);
QApplication::processEvents();
QTest::qWait(50);
}
int scrollW3 = (int)sci->SendScintilla(QsciScintillaBase::SCI_GETSCROLLWIDTH);
int xOff3 = (int)sci->SendScintilla(QsciScintillaBase::SCI_GETXOFFSET);
qDebug() << QString("Long again: scrollW=%1 xOff=%2 hbar.visible=%3 hbar.max=%4")
.arg(scrollW3).arg(xOff3)
.arg(hbar->isVisible()).arg(hbar->maximum());
QVERIFY2(scrollW3 > viewW,
qPrintable(QString("scrollW=%1 should exceed vpW=%2 after re-widen")
.arg(scrollW3).arg(viewW)));
QVERIFY2(hbar->isVisible(),
"Scrollbar must reappear after content widens again");
// After fresh apply with no prior scroll, xOffset should be 0
QCOMPARE(xOff3, 0);
delete editor;
}
void testResizeGripCornerSymmetry() {
// Same constants as production ResizeGrip in main.cpp
static constexpr int kSize = 16;
static constexpr int kPad = 4;
static constexpr double kInset = 4.0;
class Grip : public QWidget {
public:
explicit Grip(QWidget* p) : QWidget(p) { setFixedSize(kSize, kSize); }
void reposition() {
if (auto* w = parentWidget())
move(w->width() - kSize - kPad, w->height() - kSize - kPad);
}
protected:
void paintEvent(QPaintEvent*) override {
QPainter p(this);
p.setRenderHint(QPainter::Antialiasing);
p.setPen(Qt::NoPen);
p.setBrush(Qt::red);
const double r = 1.0, s = 4.0;
double bx = width() - kInset;
double by = height() - kInset;
p.drawEllipse(QPointF(bx, by), r, r);
p.drawEllipse(QPointF(bx - s, by), r, r);
p.drawEllipse(QPointF(bx - 2 * s, by), r, r);
p.drawEllipse(QPointF(bx, by - s), r, r);
p.drawEllipse(QPointF(bx - s, by - s), r, r);
p.drawEllipse(QPointF(bx, by - 2 * s), r, r);
}
};
// Helper: grab window, find bottommost-rightmost red pixel, measure gaps
auto measureGaps = [](QWidget* win, int& gapRight, int& gapBottom) -> bool {
QPixmap px = win->grab();
QImage img = px.toImage().convertToFormat(QImage::Format_ARGB32);
int W = img.width(), H = img.height();
if (W < 50 || H < 50) return false;
int foundX = -1, foundY = -1;
for (int y = H - 1; y >= H - 40 && foundY < 0; --y) {
for (int x = W - 1; x >= W - 40; --x) {
QColor c(img.pixel(x, y));
if (c.red() > 180 && c.green() < 80 && c.blue() < 80) {
foundX = x; foundY = y; break;
}
}
}
if (foundX < 0) return false;
gapRight = (W - 1) - foundX;
gapBottom = (H - 1) - foundY;
// Save diagnostic image
QImage diag = img.copy();
QPainter dp(&diag);
dp.setPen(QPen(Qt::cyan, 1));
dp.drawRect(foundX - 3, foundY - 3, 6, 6);
dp.setPen(QPen(Qt::yellow, 1));
dp.drawLine(foundX, foundY, W - 1, foundY);
dp.drawLine(foundX, foundY, foundX, H - 1);
dp.end();
diag.save("grip_corner_diag.png");
return true;
};
// --- Round 1: default system font ---
QMainWindow win;
win.resize(500, 375);
QPalette pal;
pal.setColor(QPalette::Window, QColor(30, 30, 30));
win.setPalette(pal);
win.statusBar()->setPalette(pal);
win.statusBar()->setAutoFillBackground(true);
auto* grip = new Grip(&win);
grip->raise();
win.show();
QVERIFY(QTest::qWaitForWindowExposed(&win));
grip->reposition();
QTest::qWait(100);
int gapR1 = 0, gapB1 = 0;
QVERIFY2(measureGaps(&win, gapR1, gapB1),
"Could not find red grip dot (round 1)");
QVERIFY2(gapR1 == gapB1,
qPrintable(QString("Round 1 asymmetric: gapRight=%1 gapBottom=%2")
.arg(gapR1).arg(gapB1)));
// --- Round 2: large font on status bar (must NOT change grip position) ---
QFont bigFont("Arial", 24);
win.statusBar()->setFont(bigFont);
QTest::qWait(100);
grip->reposition();
QTest::qWait(100);
int gapR2 = 0, gapB2 = 0;
QVERIFY2(measureGaps(&win, gapR2, gapB2),
"Could not find red grip dot (round 2, big font)");
QVERIFY2(gapR2 == gapB2,
qPrintable(QString("Round 2 asymmetric: gapRight=%1 gapBottom=%2")
.arg(gapR2).arg(gapB2)));
// Gaps must be identical across both font sizes
QVERIFY2(gapR1 == gapR2 && gapB1 == gapB2,
qPrintable(QString("Font changed grip position: "
"round1=(%1,%2) round2=(%3,%4)")
.arg(gapR1).arg(gapB1).arg(gapR2).arg(gapB2)));
qDebug() << "Grip corner symmetry:"
<< QString("gapRight=%1 gapBottom=%2 (font-independent)")
.arg(gapR1).arg(gapB1);
}
// ── Test: hovering struct type name shows PointingHand cursor ──
// Regression: headerTypeNameSpan returned invalid for named structs
// because it assumed "struct TYPENAME" format, but named structs are
// formatted as just "TYPENAME" (e.g. "_STRING64 CSDVersion").
void testStructTypeClickable() {
m_editor->applyDocument(m_result);
QApplication::processEvents();
// Find a named struct header (e.g. _STRING64 CSDVersion from makeTestTree)
int headerLine = -1;
for (int i = 0; i < m_result.meta.size(); i++) {
const auto& lm = m_result.meta[i];
if (lm.lineKind == LineKind::Header && lm.foldHead
&& lm.nodeKind == NodeKind::Struct && !lm.isArrayHeader) {
headerLine = i;
break;
}
}
QVERIFY2(headerLine >= 0, "Should have a struct header");
const LineMeta* lm = m_editor->metaForLine(headerLine);
QVERIFY(lm);
// Scroll to ensure line is visible
m_editor->scintilla()->SendScintilla(
QsciScintillaBase::SCI_ENSUREVISIBLE, (unsigned long)headerLine);
m_editor->scintilla()->SendScintilla(
QsciScintillaBase::SCI_GOTOLINE, (unsigned long)headerLine);
QApplication::processEvents();
// The type column starts at kFoldCol + depth*3
int typeStart = 3 + lm->depth * 3; // kFoldCol = 3
// Hover over type column — should show PointingHandCursor
// (Before fix: showed ArrowCursor because headerTypeNameSpan returned invalid)
QPoint typePos = colToViewport(m_editor->scintilla(), headerLine, typeStart + 1);
QVERIFY2(typePos.y() > 0, "Header line should be visible");
sendMouseMove(m_editor->scintilla()->viewport(), typePos);
QApplication::processEvents();
QCOMPARE(viewportCursor(m_editor), Qt::PointingHandCursor);
}
// ── Static field: name must be editable (it's a function name, not hex label) ──
void testStaticFieldNameEditable() {
// Build a tree with one regular field + one static field
NodeTree tree;
tree.baseAddress = 0;
Node root;
root.kind = NodeKind::Struct;
root.name = "Test";
root.structTypeName = "Test";
root.parentId = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
Node f;
f.kind = NodeKind::UInt32;
f.name = "field_a";
f.parentId = rootId;
f.offset = 0;
tree.addNode(f);
Node sf;
sf.kind = NodeKind::Hex64;
sf.name = "my_target";
sf.parentId = rootId;
sf.offset = 0;
sf.isStatic = true;
sf.offsetExpr = QStringLiteral("base");
tree.addNode(sf);
NullProvider prov;
ComposeResult result = compose(tree, prov);
m_editor->applyDocument(result);
QApplication::processEvents();
// Find the static field header line
int headerLine = -1;
for (int i = 0; i < result.meta.size(); i++) {
if (result.meta[i].isStaticLine && result.meta[i].lineKind == LineKind::Header) {
headerLine = i;
break;
}
}
QVERIFY2(headerLine >= 0, "Should have a static field header line");
const LineMeta* lm = m_editor->metaForLine(headerLine);
QVERIFY(lm);
QVERIFY(lm->isStaticLine);
// Verify the header text contains the name
QString text = m_editor->textWithMargins();
QStringList lines = text.split('\n');
QVERIFY2(headerLine < lines.size(), "header line in range");
QString hdrText = lines[headerLine];
QVERIFY2(hdrText.contains("my_target"), qPrintable("Header line should contain name: " + hdrText));
// The name should be inline-editable despite being a hex node kind
int nameStart = kFoldCol + lm->depth * 3 + lm->effectiveTypeW + kSepWidth;
bool ok = m_editor->beginInlineEdit(EditTarget::Name, headerLine, nameStart);
QVERIFY2(ok, qPrintable(QString("Static field name must be editable. line=%1 col=%2 depth=%3 typeW=%4 text='%5'")
.arg(headerLine).arg(nameStart).arg(lm->depth).arg(lm->effectiveTypeW).arg(hdrText)));
m_editor->cancelInlineEdit();
}
// ── Static field: type in header triggers type picker, not inline edit ──
void testStaticFieldTypeClickable() {
// Build same tree as above
NodeTree tree;
tree.baseAddress = 0;
Node root;
root.kind = NodeKind::Struct;
root.name = "Test";
root.structTypeName = "Test";
root.parentId = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
Node f;
f.kind = NodeKind::UInt32;
f.name = "field_a";
f.parentId = rootId;
f.offset = 0;
tree.addNode(f);
Node sf;
sf.kind = NodeKind::Hex64;
sf.name = "my_target";
sf.parentId = rootId;
sf.offset = 0;
sf.isStatic = true;
sf.offsetExpr = QStringLiteral("base");
tree.addNode(sf);
NullProvider prov;
ComposeResult result = compose(tree, prov);
m_editor->applyDocument(result);
QApplication::processEvents();
// Find the static field header line
int headerLine = -1;
for (int i = 0; i < result.meta.size(); i++) {
if (result.meta[i].isStaticLine && result.meta[i].lineKind == LineKind::Header) {
headerLine = i;
break;
}
}
QVERIFY(headerLine >= 0);
const LineMeta* lm = m_editor->metaForLine(headerLine);
QVERIFY(lm);
// Scroll to ensure visible
m_editor->scintilla()->SendScintilla(
QsciScintillaBase::SCI_ENSUREVISIBLE, (unsigned long)headerLine);
m_editor->scintilla()->SendScintilla(
QsciScintillaBase::SCI_GOTOLINE, (unsigned long)headerLine);
QApplication::processEvents();
// Hover over the type column (after "static " prefix) — should be PointingHandCursor
// "static " is 7 chars, so the actual type starts at indent + 7
int typeCol = kFoldCol + lm->depth * 3 + 7;
QPoint typePos = colToViewport(m_editor->scintilla(), headerLine, typeCol + 1);
if (typePos.y() > 0) {
sendMouseMove(m_editor->scintilla()->viewport(), typePos);
QApplication::processEvents();
QCOMPARE(viewportCursor(m_editor), Qt::PointingHandCursor);
}
}
// ── Static field: body line expression is editable ──
void testStaticFieldExprEditable() {
NodeTree tree;
tree.baseAddress = 0;
Node root;
root.kind = NodeKind::Struct;
root.name = "Test";
root.structTypeName = "Test";
root.parentId = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
Node sf;
sf.kind = NodeKind::Hex64;
sf.name = "target";
sf.parentId = rootId;
sf.offset = 0;
sf.isStatic = true;
sf.offsetExpr = QStringLiteral("base + 0x10");
sf.collapsed = false;
tree.addNode(sf);
NullProvider prov;
ComposeResult result = compose(tree, prov);
m_editor->applyDocument(result);
QApplication::processEvents();
// Find the body line (Field with isStaticLine)
int bodyLine = -1;
for (int i = 0; i < result.meta.size(); i++) {
if (result.meta[i].isStaticLine && result.meta[i].lineKind == LineKind::Field) {
bodyLine = i;
break;
}
}
QVERIFY2(bodyLine >= 0, "Should have a static field body line");
// The expression should be editable via StaticExpr target
bool ok = m_editor->beginInlineEdit(EditTarget::StaticExpr, bodyLine);
QVERIFY2(ok, "Static field expression must be editable");
m_editor->cancelInlineEdit();
}
// ── No separator line for static fields ──
void testStaticFieldNoSeparator() {
NodeTree tree;
tree.baseAddress = 0;
Node root;
root.kind = NodeKind::Struct;
root.name = "Test";
root.structTypeName = "Test";
root.parentId = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
Node f;
f.kind = NodeKind::UInt32;
f.name = "a";
f.parentId = rootId;
f.offset = 0;
tree.addNode(f);
Node sf;
sf.kind = NodeKind::Hex64;
sf.name = "target";
sf.parentId = rootId;
sf.offset = 0;
sf.isStatic = true;
sf.offsetExpr = QStringLiteral("base");
tree.addNode(sf);
NullProvider prov;
ComposeResult result = compose(tree, prov);
// No separator line with box-drawing characters should exist
QStringList lines = result.text.split('\n');
for (const auto& line : lines) {
QVERIFY2(!line.contains(QStringLiteral("\u2500\u2500\u2500\u2500 static \u2500\u2500\u2500\u2500")),
"Static fields should not have a separator line");
}
}
// ── Test: disasm popup dismisses when mouse moves onto it ("see-through") ──
//
// Scenario: hover a FuncPtr row → disasm popup appears below the row.
// User moves mouse down onto the popup. The popup covers rows behind it
// but the mouse position maps to a different node's row in the viewport
// underneath, so the popup must dismiss.
void testDisasmPopupDismissesOnMouseMoveThrough() {
NodeTree tree;
tree.baseAddress = 0;
Node root;
root.kind = NodeKind::Struct;
root.structTypeName = "TestClass";
root.name = "TestClass";
root.parentId = 0;
root.offset = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
// FuncPtr64 at offset 0 — its value points to "code" at byte 256
Node fp;
fp.kind = NodeKind::FuncPtr64;
fp.name = "VFunc1";
fp.parentId = rootId;
fp.offset = 0;
tree.addNode(fp);
// A plain UInt64 after it so there's a non-FuncPtr row below
Node pad;
pad.kind = NodeKind::UInt64;
pad.name = "padding";
pad.parentId = rootId;
pad.offset = 8;
tree.addNode(pad);
// Buffer layout:
// [0..7] FuncPtr value = 256 (points to code bytes)
// [8..15] padding field value
// [256..383] x86 code bytes (push rbp; mov rbp,rsp; nop...; ret)
QByteArray data(512, '\0');
uint64_t codeAddr = 256;
memcpy(data.data(), &codeAddr, 8);
const uint8_t code[] = {
0x55, // push rbp
0x48, 0x89, 0xE5, // mov rbp, rsp
0x90, // nop
0x90, // nop
0x5D, // pop rbp
0xC3 // ret
};
memcpy(data.data() + 256, code, sizeof(code));
BufferProvider prov(data, "test_disasm_dismiss");
ComposeResult cr = compose(tree, prov);
m_editor->applyDocument(cr);
m_editor->setProviderRef(&prov, nullptr, &tree);
QApplication::processEvents();
// Find the FuncPtr line
int fpLine = -1;
for (int i = 0; i < cr.meta.size(); ++i) {
if (isFuncPtr(cr.meta[i].nodeKind)) {
fpLine = i;
break;
}
}
QVERIFY2(fpLine >= 0, "Could not find FuncPtr64 line in compose output");
// Hover over the FuncPtr value column to trigger the disasm popup
const LineMeta& lm = cr.meta[fpLine];
QString lineText;
{
long len = m_editor->scintilla()->SendScintilla(
QsciScintillaBase::SCI_LINELENGTH, (unsigned long)fpLine);
QByteArray buf(len + 1, '\0');
m_editor->scintilla()->SendScintilla(
QsciScintillaBase::SCI_GETLINE, (uintptr_t)fpLine,
static_cast<const char*>(buf.data()));
lineText = QString::fromUtf8(buf.left(len));
}
ColumnSpan vs = m_editor->valueSpan(lm, lineText.size(),
lm.effectiveTypeW, lm.effectiveNameW);
QVERIFY2(vs.valid, "Value span for FuncPtr line is not valid");
int hoverCol = (vs.start + vs.end) / 2;
QPoint vpFP = colToViewport(m_editor->scintilla(), fpLine, hoverCol);
sendMouseMove(m_editor->scintilla()->viewport(), vpFP);
QApplication::processEvents();
QWidget* popup = m_editor->disasmPopup();
QVERIFY2(popup && popup->isVisible(),
"Disasm popup should be visible after hovering the FuncPtr value");
// See-through behavior: when the user moves the mouse down from the
// viewport onto the popup, the popup's mouseMoveEvent override forwards
// the global position back to the viewport hover logic. If the row
// underneath the popup represents a different node, the popup dismisses.
//
// Simulate by sending a MouseMove event to the popup at a global
// position that maps to the CommandRow (line 0) — a non-FuncPtr row.
// sendEvent triggers the virtual mouseMoveEvent directly.
QPoint vpCmdRow = colToViewport(m_editor->scintilla(), 0, hoverCol);
QPoint globalCmdRow = m_editor->scintilla()->viewport()->mapToGlobal(vpCmdRow);
QPoint localOnPopup = popup->mapFromGlobal(globalCmdRow);
QMouseEvent moveOnPopup(QEvent::MouseMove,
QPointF(localOnPopup), QPointF(globalCmdRow),
Qt::NoButton, Qt::NoButton, Qt::NoModifier);
QApplication::sendEvent(popup, &moveOnPopup);
QApplication::processEvents();
QVERIFY2(!popup->isVisible(),
"Disasm popup must dismiss when mouseMoveEvent forwards "
"to a non-FuncPtr row underneath (see-through behavior)");
// Restore
m_editor->setProviderRef(nullptr, nullptr, nullptr);
m_editor->applyDocument(m_result);
}
};
QTEST_MAIN(TestEditor)
#include "test_editor.moc"