#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 \u00B7 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 \u00B7 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 \u00B7 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 \u00B7 0xD87B5E5000 \u00B7 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 \u00B7 0xD87B5E5000 \u00B7 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 \u00B7 0xD87B5E5000 \u00B7 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 \u00B7 0xD87B5E5000 \u00B7 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;
        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;
        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;
        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;
            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;
            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);
    }
};

QTEST_MAIN(TestEditor)
#include "test_editor.moc"
