#include #include #include #include #include #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(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 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 // from after "0x" to end. Type "FF" to replace the hex digits. for (QChar c : QString("FF")) { QKeyEvent key(QEvent::KeyPress, 0, Qt::NoModifier, QString(c)); QApplication::sendEvent(m_editor->scintilla(), &key); } 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 args = spy.first(); int nodeIdx = args.at(0).toInt(); QString text = args.at(3).toString().trimmed(); // The committed text should contain "0xFF" (hex format for UInt8) QVERIFY2(!text.isEmpty(), "Committed text should not be empty"); // 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(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(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 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(QString(), Qt::FindDirectChildrenOnly); // Even if popup widget exists, it should not be visible bool popupVisible = false; for (auto* child : m_editor->findChildren(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(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 shiftedIds; QHash 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&>(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(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&>(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"