Files
archived-Reclass/tests/test_controller.cpp
IChooseYou d22661446b feat: turn sentinel dock tab into "+" new tab button
Instead of hiding the sentinel tab (which leaked space on macOS),
repurpose it as a visible "+" button that creates a new struct tab
on click. Compact 32px icon-only tab with pixel-perfect cross drawn
via fillRect. Skips context menu and middle-click. Always positioned
as the last tab in the group.
2026-03-16 07:39:18 -06:00

916 lines
33 KiB
C++

#include <QtTest/QTest>
#include <QtTest/QSignalSpy>
#include <QApplication>
#include <QSplitter>
#include <Qsci/qsciscintilla.h>
#include "controller.h"
#include "core.h"
using namespace rcx;
// Provider with a configurable base address (for testing source-switch logic)
class BaseAwareProvider : public Provider {
QByteArray m_data;
uint64_t m_base;
public:
BaseAwareProvider(QByteArray data, uint64_t base)
: m_data(std::move(data)), m_base(base) {}
bool read(uint64_t addr, void* buf, int len) const override {
if (addr + len > (uint64_t)m_data.size()) return false;
std::memcpy(buf, m_data.constData() + addr, len);
return true;
}
int size() const override { return m_data.size(); }
uint64_t base() const override { return m_base; }
bool isLive() const override { return true; }
QString name() const override { return QStringLiteral("test"); }
QString kind() const override { return QStringLiteral("Process"); }
};
// Small tree: one root struct with a few typed fields at known offsets.
// Keeps tests fast and deterministic (no giant PEB tree).
static void buildSmallTree(NodeTree& tree) {
tree.baseAddress = 0;
Node root;
root.kind = NodeKind::Struct;
root.structTypeName = "TestStruct";
root.name = "root";
root.parentId = 0;
root.offset = 0;
root.collapsed = false;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
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);
};
field(0, NodeKind::UInt32, "field_u32"); // 4 bytes
field(4, NodeKind::Float, "field_float"); // 4 bytes
field(8, NodeKind::UInt8, "field_u8"); // 1 byte
field(9, NodeKind::Hex16, "pad0"); // 2 bytes
field(11, NodeKind::Hex8, "pad1"); // 1 byte
field(12, NodeKind::Hex32, "field_hex"); // 4 bytes
}
// 64-byte buffer with recognizable pattern
static QByteArray makeSmallBuffer() {
QByteArray data(64, '\0');
// field_u32 at offset 0 = 0xDEADBEEF
uint32_t v32 = 0xDEADBEEF;
memcpy(data.data() + 0, &v32, 4);
// field_float at offset 4 = 3.14f
float vf = 3.14f;
memcpy(data.data() + 4, &vf, 4);
// field_u8 at offset 8 = 0x42
data[8] = 0x42;
// pad0 at offset 9 = 0x00 0x00 0x00
// field_hex at offset 12 = 0xCAFEBABE
uint32_t vhex = 0xCAFEBABE;
memcpy(data.data() + 12, &vhex, 4);
return data;
}
class TestController : public QObject {
Q_OBJECT
private:
RcxDocument* m_doc = nullptr;
RcxController* m_ctrl = nullptr;
QSplitter* m_splitter = nullptr;
RcxEditor* m_editor = nullptr;
private slots:
void init() {
m_doc = new RcxDocument();
buildSmallTree(m_doc->tree);
m_doc->provider = std::make_unique<BufferProvider>(makeSmallBuffer());
m_splitter = new QSplitter();
// Pass nullptr as parent so controller is not auto-deleted with splitter
m_ctrl = new RcxController(m_doc, nullptr);
m_editor = m_ctrl->addSplitEditor(m_splitter);
m_splitter->resize(800, 600);
m_splitter->show();
QVERIFY(QTest::qWaitForWindowExposed(m_splitter));
QApplication::processEvents();
}
void cleanup() {
// Delete controller first (disconnects from editor signals)
delete m_ctrl;
m_ctrl = nullptr;
m_editor = nullptr; // owned by splitter
delete m_splitter;
m_splitter = nullptr;
delete m_doc;
m_doc = nullptr;
}
// ── Test: setNodeValue writes bytes to provider ──
void testSetNodeValueWritesData() {
// Find field_u32 (index 1, child of root at index 0)
int idx = -1;
for (int i = 0; i < m_doc->tree.nodes.size(); i++) {
if (m_doc->tree.nodes[i].name == "field_u32") { idx = i; break; }
}
QVERIFY(idx >= 0);
// Verify original value in provider
uint64_t addr = m_doc->tree.computeOffset(idx);
QByteArray origBytes = m_doc->provider->readBytes(addr, 4);
uint32_t origVal;
memcpy(&origVal, origBytes.data(), 4);
QCOMPARE(origVal, (uint32_t)0xDEADBEEF);
// Write new value "42" (decimal)
m_ctrl->setNodeValue(idx, 0, "42");
QApplication::processEvents();
// Read back: should be 42 in little-endian
QByteArray newBytes = m_doc->provider->readBytes(addr, 4);
uint32_t newVal;
memcpy(&newVal, newBytes.data(), 4);
QCOMPARE(newVal, (uint32_t)42);
}
// ── Test: setNodeValue undo/redo restores data ──
void testSetNodeValueUndoRedo() {
int idx = -1;
for (int i = 0; i < m_doc->tree.nodes.size(); i++) {
if (m_doc->tree.nodes[i].name == "field_u32") { idx = i; break; }
}
QVERIFY(idx >= 0);
uint64_t addr = m_doc->tree.computeOffset(idx);
// Original: 0xDEADBEEF
QByteArray orig = m_doc->provider->readBytes(addr, 4);
uint32_t origVal;
memcpy(&origVal, orig.data(), 4);
QCOMPARE(origVal, (uint32_t)0xDEADBEEF);
// Write new value
m_ctrl->setNodeValue(idx, 0, "99");
QApplication::processEvents();
uint32_t newVal;
QByteArray after = m_doc->provider->readBytes(addr, 4);
memcpy(&newVal, after.data(), 4);
QCOMPARE(newVal, (uint32_t)99);
// Undo → should restore original
m_doc->undoStack.undo();
QApplication::processEvents();
QByteArray undone = m_doc->provider->readBytes(addr, 4);
uint32_t undoneVal;
memcpy(&undoneVal, undone.data(), 4);
QCOMPARE(undoneVal, (uint32_t)0xDEADBEEF);
// Redo → should restore new value
m_doc->undoStack.redo();
QApplication::processEvents();
QByteArray redone = m_doc->provider->readBytes(addr, 4);
uint32_t redoneVal;
memcpy(&redoneVal, redone.data(), 4);
QCOMPARE(redoneVal, (uint32_t)99);
}
// ── Test: setNodeValue on Float field ──
void testSetNodeValueFloat() {
int idx = -1;
for (int i = 0; i < m_doc->tree.nodes.size(); i++) {
if (m_doc->tree.nodes[i].name == "field_float") { idx = i; break; }
}
QVERIFY(idx >= 0);
uint64_t addr = m_doc->tree.computeOffset(idx);
// Original: 3.14f
QByteArray orig = m_doc->provider->readBytes(addr, 4);
float origVal;
memcpy(&origVal, orig.data(), 4);
QVERIFY(qAbs(origVal - 3.14f) < 0.01f);
// Write "1.5"
m_ctrl->setNodeValue(idx, 0, "1.5");
QApplication::processEvents();
QByteArray after = m_doc->provider->readBytes(addr, 4);
float newVal;
memcpy(&newVal, after.data(), 4);
QCOMPARE(newVal, 1.5f);
// Undo
m_doc->undoStack.undo();
QApplication::processEvents();
QByteArray undone = m_doc->provider->readBytes(addr, 4);
float undoneVal;
memcpy(&undoneVal, undone.data(), 4);
QVERIFY(qAbs(undoneVal - 3.14f) < 0.01f);
}
// ── Test: renameNode changes name and undo restores ──
void testRenameNode() {
int idx = -1;
for (int i = 0; i < m_doc->tree.nodes.size(); i++) {
if (m_doc->tree.nodes[i].name == "field_u32") { idx = i; break; }
}
QVERIFY(idx >= 0);
QCOMPARE(m_doc->tree.nodes[idx].name, QString("field_u32"));
m_ctrl->renameNode(idx, "myRenamedField");
QApplication::processEvents();
QCOMPARE(m_doc->tree.nodes[idx].name, QString("myRenamedField"));
// Undo
m_doc->undoStack.undo();
QApplication::processEvents();
QCOMPARE(m_doc->tree.nodes[idx].name, QString("field_u32"));
// Redo
m_doc->undoStack.redo();
QApplication::processEvents();
QCOMPARE(m_doc->tree.nodes[idx].name, QString("myRenamedField"));
}
// ── Test: changeNodeKind changes type and undo restores ──
void testChangeNodeKind() {
int idx = -1;
for (int i = 0; i < m_doc->tree.nodes.size(); i++) {
if (m_doc->tree.nodes[i].name == "field_u32") { idx = i; break; }
}
QVERIFY(idx >= 0);
QCOMPARE(m_doc->tree.nodes[idx].kind, NodeKind::UInt32);
m_ctrl->changeNodeKind(idx, NodeKind::Float);
QApplication::processEvents();
QCOMPARE(m_doc->tree.nodes[idx].kind, NodeKind::Float);
// Undo
m_doc->undoStack.undo();
QApplication::processEvents();
QCOMPARE(m_doc->tree.nodes[idx].kind, NodeKind::UInt32);
}
// ── Test: insertNode adds a node, removeNode removes it, undo restores ──
void testInsertAndRemoveNode() {
int origSize = m_doc->tree.nodes.size();
uint64_t rootId = m_doc->tree.nodes[0].id;
// Insert a new Hex64 at offset 16
m_ctrl->insertNode(rootId, 16, NodeKind::Hex64, "newHex");
QApplication::processEvents();
QCOMPARE(m_doc->tree.nodes.size(), origSize + 1);
// Find the inserted node
int newIdx = -1;
for (int i = 0; i < m_doc->tree.nodes.size(); i++) {
if (m_doc->tree.nodes[i].name == "newHex") { newIdx = i; break; }
}
QVERIFY(newIdx >= 0);
QCOMPARE(m_doc->tree.nodes[newIdx].kind, NodeKind::Hex64);
QCOMPARE(m_doc->tree.nodes[newIdx].offset, 16);
// Remove it
m_ctrl->removeNode(newIdx);
QApplication::processEvents();
QCOMPARE(m_doc->tree.nodes.size(), origSize);
// Undo remove → node restored
m_doc->undoStack.undo();
QApplication::processEvents();
QCOMPARE(m_doc->tree.nodes.size(), origSize + 1);
// Find again
newIdx = -1;
for (int i = 0; i < m_doc->tree.nodes.size(); i++) {
if (m_doc->tree.nodes[i].name == "newHex") { newIdx = i; break; }
}
QVERIFY(newIdx >= 0);
}
// ── Test: setNodeValue with Hex32 (space-separated hex bytes) ──
void testSetNodeValueHex() {
int idx = -1;
for (int i = 0; i < m_doc->tree.nodes.size(); i++) {
if (m_doc->tree.nodes[i].name == "field_hex") { idx = i; break; }
}
QVERIFY(idx >= 0);
uint64_t addr = m_doc->tree.computeOffset(idx);
// Original: 0xCAFEBABE
QByteArray orig = m_doc->provider->readBytes(addr, 4);
uint32_t origVal;
memcpy(&origVal, orig.data(), 4);
QCOMPARE(origVal, (uint32_t)0xCAFEBABE);
// Write space-separated hex bytes "AA BB CC DD"
m_ctrl->setNodeValue(idx, 0, "AA BB CC DD");
QApplication::processEvents();
QByteArray after = m_doc->provider->readBytes(addr, 4);
QCOMPARE((uint8_t)after[0], (uint8_t)0xAA);
QCOMPARE((uint8_t)after[1], (uint8_t)0xBB);
QCOMPARE((uint8_t)after[2], (uint8_t)0xCC);
QCOMPARE((uint8_t)after[3], (uint8_t)0xDD);
// Undo
m_doc->undoStack.undo();
QApplication::processEvents();
QByteArray undone = m_doc->provider->readBytes(addr, 4);
uint32_t undoneVal;
memcpy(&undoneVal, undone.data(), 4);
QCOMPARE(undoneVal, (uint32_t)0xCAFEBABE);
}
// ── Test: full inline edit round-trip (type in editor → commit → verify provider) ──
void testInlineEditRoundTrip() {
// Refresh to get composed output
m_ctrl->refresh();
QApplication::processEvents();
// Find field_u8 line (UInt8 at offset 8, value = 0x42 = 66)
ComposeResult result = m_doc->compose();
int fieldLine = -1;
for (int i = 0; i < result.meta.size(); i++) {
if (result.meta[i].nodeKind == NodeKind::UInt8 &&
result.meta[i].lineKind == LineKind::Field) {
fieldLine = i;
break;
}
}
QVERIFY(fieldLine >= 0);
m_editor->applyDocument(result);
QApplication::processEvents();
// Select this node so edit is allowed
uint64_t nodeId = result.meta[fieldLine].nodeId;
QSet<uint64_t> sel;
sel.insert(nodeId);
m_editor->applySelectionOverlay(sel);
QApplication::processEvents();
// Begin value edit
bool ok = m_editor->beginInlineEdit(EditTarget::Value, fieldLine);
QVERIFY2(ok, "Should be able to begin value edit on UInt8 field");
QVERIFY(m_editor->isEditing());
// UInt8 values display in hex (e.g., "0x42"). beginInlineEdit selects
// the value text. Replace it directly via Scintilla API (sendEvent with
// key presses doesn't reliably reach QScintilla in headless test mode).
{
QByteArray replacement = QByteArrayLiteral("0xFF");
m_editor->scintilla()->SendScintilla(
QsciScintillaBase::SCI_REPLACESEL,
(uintptr_t)0, replacement.constData());
}
QApplication::processEvents();
// Commit
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);
QList<QVariant> args = spy.first();
int nodeIdx = args.at(0).toInt();
QString text = args.at(3).toString().trimmed();
QVERIFY2(text.contains("FF", Qt::CaseInsensitive),
qPrintable(QString("Expected '0xFF', got '%1'").arg(text)));
// Now simulate what controller does: setNodeValue
m_ctrl->setNodeValue(nodeIdx, 0, text);
QApplication::processEvents();
// Verify provider data changed
int u8Idx = -1;
for (int i = 0; i < m_doc->tree.nodes.size(); i++) {
if (m_doc->tree.nodes[i].name == "field_u8") { u8Idx = i; break; }
}
QVERIFY(u8Idx >= 0);
uint64_t addr = m_doc->tree.computeOffset(u8Idx);
QByteArray bytes = m_doc->provider->readBytes(addr, 1);
QCOMPARE((uint8_t)bytes[0], (uint8_t)0xFF);
}
// ── Test: source switch preserves existing base address ──
void testSourceSwitchPreservesBase() {
// Set a non-zero baseAddress to simulate a loaded .rcx file
m_doc->tree.baseAddress = 0x1000;
QCOMPARE(m_doc->tree.baseAddress, (uint64_t)0x1000);
// Simulate attaching a new provider whose base differs (e.g. 0x400000)
auto prov = std::make_shared<BaseAwareProvider>(makeSmallBuffer(), 0x400000);
uint64_t newBase = prov->base();
QCOMPARE(newBase, (uint64_t)0x400000);
m_doc->provider = prov;
// Controller logic: keep existing baseAddress when non-zero
if (m_doc->tree.baseAddress == 0)
m_doc->tree.baseAddress = newBase;
// baseAddress must stay at the original value
QCOMPARE(m_doc->tree.baseAddress, (uint64_t)0x1000);
// provider base is unchanged (no setBase sync) — provider reports its own initial base
QCOMPARE(m_doc->provider->base(), (uint64_t)0x400000);
}
// ── Test: source switch on fresh doc uses provider default ──
void testSourceSwitchFreshDocUsesProviderBase() {
// Simulate a fresh document (no loaded .rcx → baseAddress == 0)
m_doc->tree.baseAddress = 0;
auto prov = std::make_shared<BaseAwareProvider>(makeSmallBuffer(), 0x7FFE0000);
uint64_t newBase = prov->base();
m_doc->provider = prov;
if (m_doc->tree.baseAddress == 0)
m_doc->tree.baseAddress = newBase;
// Fresh doc should adopt the provider's default base
QCOMPARE(m_doc->tree.baseAddress, (uint64_t)0x7FFE0000);
}
// ── Test: toggleCollapse + undo ──
void testToggleCollapse() {
// Root is index 0, a Struct node
QCOMPARE(m_doc->tree.nodes[0].kind, NodeKind::Struct);
QCOMPARE(m_doc->tree.nodes[0].collapsed, false);
m_ctrl->toggleCollapse(0);
QApplication::processEvents();
QCOMPARE(m_doc->tree.nodes[0].collapsed, true);
m_ctrl->toggleCollapse(0);
QApplication::processEvents();
QCOMPARE(m_doc->tree.nodes[0].collapsed, false);
// Undo twice: uncollapse → collapse → original (false)
m_doc->undoStack.undo();
QApplication::processEvents();
QCOMPARE(m_doc->tree.nodes[0].collapsed, true);
m_doc->undoStack.undo();
QApplication::processEvents();
QCOMPARE(m_doc->tree.nodes[0].collapsed, false);
}
// ── Test: value history popup only appears during inline editing ──
void testValueHistoryPopupOnlyDuringEdit() {
// Record value history for field_u32 so it has heat
auto& tree = m_doc->tree;
int idx = -1;
for (int i = 0; i < tree.nodes.size(); i++) {
if (tree.nodes[i].name == "field_u32") { idx = i; break; }
}
QVERIFY(idx >= 0);
uint64_t nodeId = tree.nodes[idx].id;
QHash<uint64_t, ValueHistory> history;
history[nodeId].record("100");
history[nodeId].record("200");
history[nodeId].record("300");
QVERIFY(history[nodeId].uniqueCount() > 1);
m_editor->setValueHistoryRef(&history);
// Refresh and compose so editor has meta with heatLevel
m_ctrl->refresh();
QApplication::processEvents();
ComposeResult result = m_doc->compose();
// Manually set heat on the node's line meta
for (auto& lm : result.meta) {
if (lm.nodeId == nodeId) lm.heatLevel = 2;
}
m_editor->applyDocument(result);
QApplication::processEvents();
// Popup should not exist or not be visible (no editing active)
auto* popup = m_editor->findChild<QWidget*>(QString(), Qt::FindDirectChildrenOnly);
// Even if popup widget exists, it should not be visible
bool popupVisible = false;
for (auto* child : m_editor->findChildren<QFrame*>(QString(), Qt::FindDirectChildrenOnly)) {
if (child->isVisible() && child->windowFlags() & Qt::ToolTip)
popupVisible = true;
}
QVERIFY2(!popupVisible, "Popup should not be visible when not editing");
// Start inline edit on value column of field_u32
int fieldLine = -1;
for (int i = 0; i < result.meta.size(); i++) {
if (result.meta[i].nodeId == nodeId && result.meta[i].lineKind == LineKind::Field) {
fieldLine = i; break;
}
}
QVERIFY(fieldLine >= 0);
bool ok = m_editor->beginInlineEdit(EditTarget::Value, fieldLine);
QVERIFY(ok);
QVERIFY(m_editor->isEditing());
// Trigger hover cursor update (simulates mouse move during editing)
QApplication::processEvents();
// Cancel edit to clean up
m_editor->cancelInlineEdit();
QApplication::processEvents();
m_editor->setValueHistoryRef(nullptr);
}
// ── Test: delete node clears value history for shifted siblings ──
void testDeleteClearsHeatForShiftedNodes() {
// Replace with a live provider so refresh() actually records values
m_doc->provider = std::make_unique<BaseAwareProvider>(makeSmallBuffer(), 0x1000);
m_ctrl->refresh();
QApplication::processEvents();
auto& tree = m_doc->tree;
// Locate field_u32 (the node we'll delete) and the siblings after it.
// The small tree has: field_u32(0), field_float(4), field_u8(8),
// pad0/Hex16(9), pad1/Hex8(11), field_hex/Hex32(12)
// field_float and field_u8 are regular (non-hex) types.
int delIdx = -1;
for (int i = 0; i < tree.nodes.size(); i++) {
if (tree.nodes[i].name == "field_u32") { delIdx = i; break; }
}
QVERIFY(delIdx >= 0);
uint64_t delId = tree.nodes[delIdx].id;
// Collect sibling node IDs that come after field_u32 (will be shifted)
uint64_t parentId = tree.nodes[delIdx].parentId;
int deletedSize = tree.nodes[delIdx].byteSize(); // 4 bytes
int deletedEnd = tree.nodes[delIdx].offset + deletedSize;
QVector<uint64_t> shiftedIds;
QHash<uint64_t, QString> nameMap; // for debug messages
for (int i = 0; i < tree.nodes.size(); i++) {
if (tree.nodes[i].parentId == parentId && i != delIdx
&& tree.nodes[i].offset >= deletedEnd) {
shiftedIds.append(tree.nodes[i].id);
nameMap[tree.nodes[i].id] = tree.nodes[i].name;
}
}
QVERIFY2(!shiftedIds.isEmpty(), "Should have siblings after field_u32");
// Seed value history for shifted siblings (simulate accumulated heat)
auto& history = const_cast<QHash<uint64_t, ValueHistory>&>(m_ctrl->valueHistory());
for (uint64_t id : shiftedIds) {
history[id].record("old_val_1");
history[id].record("old_val_2");
history[id].record("old_val_3");
QVERIFY2(history[id].heatLevel() >= 2,
qPrintable(QString("Pre-delete: %1 should have heat>=2")
.arg(nameMap[id])));
}
// Also seed the to-be-deleted node
history[delId].record("del_1");
history[delId].record("del_2");
QVERIFY(history.contains(delId));
// Delete field_u32 — this shifts all subsequent siblings
m_ctrl->removeNode(delIdx);
QApplication::processEvents();
// The deleted node's history should be gone
QVERIFY2(!m_ctrl->valueHistory().contains(delId),
"Deleted node's value history should be cleared");
// All shifted siblings should have heat=0 after the delete.
// With a live provider, refresh() inside removeNode re-records one new
// value at the new offset → count=1 → heatLevel=0.
for (uint64_t id : shiftedIds) {
int heat = m_ctrl->valueHistory().contains(id)
? m_ctrl->valueHistory()[id].heatLevel() : 0;
QVERIFY2(heat == 0,
qPrintable(QString("Shifted node '%1' (id=%2) should have heat=0, got %3")
.arg(nameMap[id]).arg(id).arg(heat)));
}
}
// ── Test: value history records and cycles correctly ──
void testValueHistoryRingBuffer() {
ValueHistory vh;
QCOMPARE(vh.count, 0);
QCOMPARE(vh.heatLevel(), 0);
vh.record("10");
QCOMPARE(vh.count, 1);
QCOMPARE(vh.heatLevel(), 0); // 1 unique = static
// Duplicate should not increase count
vh.record("10");
QCOMPARE(vh.count, 1);
vh.record("20");
QCOMPARE(vh.count, 2);
QCOMPARE(vh.heatLevel(), 1); // cold
vh.record("30");
QCOMPARE(vh.count, 3);
QCOMPARE(vh.heatLevel(), 2); // warm
vh.record("40");
vh.record("50");
QCOMPARE(vh.count, 5);
QCOMPARE(vh.heatLevel(), 3); // hot
QCOMPARE(vh.last(), QString("50"));
// Ring buffer: uniqueCount() caps at kCapacity
for (int i = 0; i < 20; i++)
vh.record(QString::number(100 + i));
QCOMPARE(vh.uniqueCount(), ValueHistory::kCapacity);
QVERIFY(vh.count > ValueHistory::kCapacity);
// forEach iterates oldest→newest within ring
QStringList vals;
vh.forEach([&](const QString& v) { vals.append(v); });
QCOMPARE(vals.size(), ValueHistory::kCapacity);
QCOMPARE(vals.last(), vh.last());
}
// ── Test: inline edit "int32_t[4]" on primitive converts to array ──
void testInlineEditPrimitiveArray() {
// Find a primitive field to convert
int idx = -1;
for (int i = 0; i < m_doc->tree.nodes.size(); i++) {
if (m_doc->tree.nodes[i].name == "field_u32") { idx = i; break; }
}
QVERIFY(idx >= 0);
QCOMPARE(m_doc->tree.nodes[idx].kind, NodeKind::UInt32);
uint64_t nodeId = m_doc->tree.nodes[idx].id;
// Emit inlineEditCommitted with array syntax
emit m_editor->inlineEditCommitted(idx, 0, EditTarget::Type,
QStringLiteral("int32_t[4]"));
QApplication::processEvents();
// Node should now be an Array with elementKind=Int32, arrayLen=4
int newIdx = m_doc->tree.indexOfId(nodeId);
QVERIFY(newIdx >= 0);
QCOMPARE(m_doc->tree.nodes[newIdx].kind, NodeKind::Array);
QCOMPARE(m_doc->tree.nodes[newIdx].elementKind, NodeKind::Int32);
QCOMPARE(m_doc->tree.nodes[newIdx].arrayLen, 4);
// Undo should restore to UInt32
m_doc->undoStack.undo();
QApplication::processEvents();
newIdx = m_doc->tree.indexOfId(nodeId);
QVERIFY(newIdx >= 0);
QCOMPARE(m_doc->tree.nodes[newIdx].kind, NodeKind::UInt32);
}
// ── Static field node controller tests ──
void testAddStaticField() {
uint64_t rootId = m_doc->tree.nodes[0].id;
int origSize = m_doc->tree.nodes.size();
// Simulate "Add Static Field" — same code as context menu action
Node sf;
sf.id = m_doc->tree.m_nextId++;
sf.kind = NodeKind::Hex64;
sf.name = QStringLiteral("static_field");
sf.parentId = rootId;
sf.offset = 0;
sf.isStatic = true;
sf.offsetExpr = QStringLiteral("base");
m_doc->undoStack.push(new RcxCommand(m_ctrl, cmd::Insert{sf, {}}));
QApplication::processEvents();
QCOMPARE(m_doc->tree.nodes.size(), origSize + 1);
const auto& h = m_doc->tree.nodes.back();
QCOMPARE(h.isStatic, true);
QCOMPARE(h.offsetExpr, QStringLiteral("base"));
QCOMPARE(h.name, QStringLiteral("static_field"));
QCOMPARE(h.parentId, rootId);
}
void testAddStaticFieldUndo() {
uint64_t rootId = m_doc->tree.nodes[0].id;
int origSize = m_doc->tree.nodes.size();
Node sf;
sf.id = m_doc->tree.m_nextId++;
sf.kind = NodeKind::Hex64;
sf.name = QStringLiteral("static_field");
sf.parentId = rootId;
sf.offset = 0;
sf.isStatic = true;
sf.offsetExpr = QStringLiteral("base");
m_doc->undoStack.push(new RcxCommand(m_ctrl, cmd::Insert{sf, {}}));
QApplication::processEvents();
QCOMPARE(m_doc->tree.nodes.size(), origSize + 1);
// Undo: static field should be gone
m_doc->undoStack.undo();
QApplication::processEvents();
QCOMPARE(m_doc->tree.nodes.size(), origSize);
// Redo: static field should be back
m_doc->undoStack.redo();
QApplication::processEvents();
QCOMPARE(m_doc->tree.nodes.size(), origSize + 1);
QCOMPARE(m_doc->tree.nodes.back().isStatic, true);
}
void testChangeStaticFieldExpression() {
uint64_t rootId = m_doc->tree.nodes[0].id;
// Add a static field
Node sf;
sf.id = m_doc->tree.m_nextId++;
sf.kind = NodeKind::Hex64;
sf.name = QStringLiteral("static_field");
sf.parentId = rootId;
sf.offset = 0;
sf.isStatic = true;
sf.offsetExpr = QStringLiteral("base");
m_doc->undoStack.push(new RcxCommand(m_ctrl, cmd::Insert{sf, {}}));
QApplication::processEvents();
uint64_t sfId = m_doc->tree.nodes.back().id;
// Change expression
m_doc->undoStack.push(new RcxCommand(m_ctrl,
cmd::ChangeOffsetExpr{sfId, QStringLiteral("base"), QStringLiteral("base + 0x10")}));
QApplication::processEvents();
int idx = m_doc->tree.indexOfId(sfId);
QVERIFY(idx >= 0);
QCOMPARE(m_doc->tree.nodes[idx].offsetExpr, QStringLiteral("base + 0x10"));
// Undo: old expression restored
m_doc->undoStack.undo();
QApplication::processEvents();
idx = m_doc->tree.indexOfId(sfId);
QVERIFY(idx >= 0);
QCOMPARE(m_doc->tree.nodes[idx].offsetExpr, QStringLiteral("base"));
}
void testDeleteStaticFieldPreservesStructSize() {
uint64_t rootId = m_doc->tree.nodes[0].id;
int spanBefore = m_doc->tree.structSpan(rootId);
// Add a static field
Node sf;
sf.id = m_doc->tree.m_nextId++;
sf.kind = NodeKind::Hex64;
sf.name = QStringLiteral("static_field");
sf.parentId = rootId;
sf.offset = 0;
sf.isStatic = true;
sf.offsetExpr = QStringLiteral("base");
m_doc->undoStack.push(new RcxCommand(m_ctrl, cmd::Insert{sf, {}}));
QApplication::processEvents();
// Struct size unchanged after adding static field
QCOMPARE(m_doc->tree.structSpan(rootId), spanBefore);
// Remove static field
uint64_t sfId = m_doc->tree.nodes.back().id;
m_doc->undoStack.push(new RcxCommand(m_ctrl, cmd::Remove{sfId}));
QApplication::processEvents();
// Struct size still unchanged
QCOMPARE(m_doc->tree.structSpan(rootId), spanBefore);
}
void testStaticFieldRenamePreservesExpression() {
uint64_t rootId = m_doc->tree.nodes[0].id;
// Add a static field
Node sf;
sf.id = m_doc->tree.m_nextId++;
sf.kind = NodeKind::Hex64;
sf.name = QStringLiteral("my_static");
sf.parentId = rootId;
sf.offset = 0;
sf.isStatic = true;
sf.offsetExpr = QStringLiteral("base + field_u32");
m_doc->undoStack.push(new RcxCommand(m_ctrl, cmd::Insert{sf, {}}));
QApplication::processEvents();
uint64_t sfId = m_doc->tree.nodes.back().id;
// Rename the static field
m_doc->undoStack.push(new RcxCommand(m_ctrl,
cmd::Rename{sfId, QStringLiteral("my_static"), QStringLiteral("renamed_static")}));
QApplication::processEvents();
int idx = m_doc->tree.indexOfId(sfId);
QVERIFY(idx >= 0);
QCOMPARE(m_doc->tree.nodes[idx].name, QStringLiteral("renamed_static"));
// Expression should be preserved
QCOMPARE(m_doc->tree.nodes[idx].offsetExpr, QStringLiteral("base + field_u32"));
QCOMPARE(m_doc->tree.nodes[idx].isStatic, true);
}
// ── Test: clearing value history actually resets heat to 0 ──
void testClearValueHistoryResetsHeat() {
// Use a live provider so value tracking runs during refresh()
m_doc->provider = std::make_unique<BaseAwareProvider>(makeSmallBuffer(), 0);
m_ctrl->setTrackValues(true);
// Do initial refresh to populate m_lastResult.meta
m_ctrl->refresh();
QApplication::processEvents();
// Find field_u32 nodeId
uint64_t targetId = 0;
for (const auto& n : m_doc->tree.nodes) {
if (n.name == "field_u32") { targetId = n.id; break; }
}
QVERIFY(targetId != 0);
// Seed value history with multiple changes to get heat > 0
auto& history = const_cast<QHash<uint64_t, ValueHistory>&>(m_ctrl->valueHistory());
history[targetId].record("val_1");
history[targetId].record("val_2");
history[targetId].record("val_3");
QVERIFY2(history[targetId].heatLevel() >= 2,
"Pre-clear: should have heat >= 2 (warm)");
// Refresh so heatLevel propagates to LineMeta
m_ctrl->refresh();
QApplication::processEvents();
// Verify heat is visible in meta
bool foundHot = false;
for (const auto& lm : m_ctrl->lastResult().meta) {
if (lm.nodeId == targetId && lm.heatLevel > 0) {
foundHot = true;
break;
}
}
QVERIFY2(foundHot, "Pre-clear: LineMeta should show heat > 0");
// Now simulate what the "Clear Value History" context menu does:
// remove from history map + clear subtree + refresh
history.remove(targetId);
for (int ci : m_doc->tree.subtreeIndices(targetId))
history.remove(m_doc->tree.nodes[ci].id);
m_ctrl->refresh();
QApplication::processEvents();
// After clear + refresh, heatLevel must be 0 for this node
for (const auto& lm : m_ctrl->lastResult().meta) {
if (lm.nodeId == targetId) {
QCOMPARE(lm.heatLevel, 0);
}
}
// The history entry should exist again (re-recorded by refresh)
// but with only 1 unique value → heatLevel 0
QVERIFY(history.contains(targetId));
QCOMPARE(history[targetId].heatLevel(), 0);
QCOMPARE(history[targetId].uniqueCount(), 1);
}
void testStaticFieldTypeChangePreservesFlags() {
uint64_t rootId = m_doc->tree.nodes[0].id;
Node sf;
sf.id = m_doc->tree.m_nextId++;
sf.kind = NodeKind::Hex64;
sf.name = QStringLiteral("static_field");
sf.parentId = rootId;
sf.offset = 0;
sf.isStatic = true;
sf.offsetExpr = QStringLiteral("base");
m_doc->undoStack.push(new RcxCommand(m_ctrl, cmd::Insert{sf, {}}));
QApplication::processEvents();
uint64_t sfId = m_doc->tree.nodes.back().id;
// Change kind to UInt32
m_doc->undoStack.push(new RcxCommand(m_ctrl,
cmd::ChangeKind{sfId, NodeKind::Hex64, NodeKind::UInt32}));
QApplication::processEvents();
int idx = m_doc->tree.indexOfId(sfId);
QVERIFY(idx >= 0);
QCOMPARE(m_doc->tree.nodes[idx].kind, NodeKind::UInt32);
// Static field flags must survive type change
QCOMPARE(m_doc->tree.nodes[idx].isStatic, true);
QCOMPARE(m_doc->tree.nodes[idx].offsetExpr, QStringLiteral("base"));
}
};
QTEST_MAIN(TestController)
#include "test_controller.moc"