diff --git a/CMakeLists.txt b/CMakeLists.txt index c0925d6..715b64e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -143,4 +143,13 @@ if(BUILD_TESTING) target_include_directories(test_generator PRIVATE src) target_link_libraries(test_generator PRIVATE Qt6::Core Qt6::Test) add_test(NAME test_generator COMMAND test_generator) + + add_executable(test_context_menu tests/test_context_menu.cpp + src/editor.cpp src/compose.cpp src/format.cpp src/controller.cpp + src/processpicker.cpp src/processpicker.ui) + target_include_directories(test_context_menu PRIVATE src) + target_link_libraries(test_context_menu PRIVATE + Qt6::Widgets Qt6::PrintSupport Qt6::Test + QScintilla::QScintilla dbghelp psapi) + add_test(NAME test_context_menu COMMAND test_context_menu) endif() diff --git a/src/controller.cpp b/src/controller.cpp index 09eabf7..d6119f5 100644 --- a/src/controller.cpp +++ b/src/controller.cpp @@ -740,10 +740,19 @@ void RcxController::duplicateNode(int nodeIdx) { void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx, int subLine, const QPoint& globalPos) { - // Empty area or CommandRow: show limited menu + auto icon = [](const char* name) { return QIcon(QStringLiteral(":/vsicons/%1").arg(name)); }; + + // Empty area or CommandRow: show full creation menu if (nodeIdx < 0 || nodeIdx >= m_doc->tree.nodes.size()) { QMenu menu; - menu.addAction("Append 128 bytes", [this]() { + menu.addAction(icon("add.svg"), "&Add Hex64 Field", [this]() { + insertNode(0, -1, NodeKind::Hex64, "newField"); + }); + menu.addAction(icon("symbol-structure.svg"), "Add &Struct", [this]() { + insertNode(0, -1, NodeKind::Struct, "NewClass"); + }); + menu.addSeparator(); + menu.addAction(icon("diff-added.svg"), "Append &128 bytes", [this]() { m_suppressRefresh = true; m_doc->undoStack.beginMacro(QStringLiteral("Append 128 bytes")); for (int i = 0; i < 16; i++) @@ -753,6 +762,17 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx, m_suppressRefresh = false; refresh(); }); + menu.addSeparator(); + menu.addAction(icon("arrow-left.svg"), "&Undo", [this]() { + m_doc->undoStack.undo(); + })->setEnabled(m_doc->undoStack.canUndo()); + menu.addAction(icon("arrow-right.svg"), "&Redo", [this]() { + m_doc->undoStack.redo(); + })->setEnabled(m_doc->undoStack.canRedo()); + menu.addSeparator(); + menu.addAction(icon("clippy.svg"), "Copy All as &Text", [editor]() { + QApplication::clipboard()->setText(editor->scintilla()->text()); + }); menu.exec(globalPos); return; } @@ -771,7 +791,7 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx, QMenu menu; int count = m_selIds.size(); QSet ids = m_selIds; - menu.addAction(QString("Delete %1 nodes").arg(count), [this, ids]() { + menu.addAction(icon("trash.svg"), QString("Delete %1 nodes").arg(count), [this, ids]() { QVector indices; for (uint64_t id : ids) { int idx = m_doc->tree.indexOfId(id); @@ -779,7 +799,7 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx, } batchRemoveNodes(indices); }); - menu.addAction(QString("Change type of %1 nodes...").arg(count), + menu.addAction(icon("symbol-structure.svg"), QString("Change type of %1 nodes...").arg(count), [this, ids]() { QStringList types; for (const auto& e : kKindMeta) types << e.name; @@ -810,48 +830,54 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx, && node.kind != NodeKind::Padding && node.kind != NodeKind::Mat4x4 && m_doc->provider->isWritable(); if (isEditable) { - menu.addAction("Edit &Value\tEnter", [editor, line]() { + menu.addAction(icon("edit.svg"), "Edit &Value\tEnter", [editor, line]() { editor->beginInlineEdit(EditTarget::Value, line); }); } - menu.addAction("Re&name\tF2", [editor, line]() { + menu.addAction(icon("rename.svg"), "Re&name\tF2", [editor, line]() { editor->beginInlineEdit(EditTarget::Name, line); }); - menu.addAction("Change &Type\tT", [editor, line]() { + menu.addAction(icon("symbol-structure.svg"), "Change &Type\tT", [editor, line]() { editor->beginInlineEdit(EditTarget::Type, line); }); menu.addSeparator(); - menu.addAction("&Add Field Below\tInsert", [this, parentId]() { + menu.addAction(icon("add.svg"), "&Add Field Below\tInsert", [this, parentId]() { insertNode(parentId, -1, NodeKind::Hex64, "newField"); }); if (node.kind == NodeKind::Struct || node.kind == NodeKind::Array) { - menu.addAction("Add &Child", [this, nodeId]() { + menu.addAction(icon("diff-added.svg"), "Add &Child", [this, nodeId]() { insertNode(nodeId, 0, NodeKind::Hex64, "newField"); }); - QString colText = node.collapsed ? "&Expand" : "&Collapse"; - menu.addAction(colText, [this, nodeId]() { - int ni = m_doc->tree.indexOfId(nodeId); - if (ni >= 0) toggleCollapse(ni); - }); + if (node.collapsed) { + menu.addAction(icon("expand-all.svg"), "&Expand", [this, nodeId]() { + int ni = m_doc->tree.indexOfId(nodeId); + if (ni >= 0) toggleCollapse(ni); + }); + } else { + menu.addAction(icon("collapse-all.svg"), "&Collapse", [this, nodeId]() { + int ni = m_doc->tree.indexOfId(nodeId); + if (ni >= 0) toggleCollapse(ni); + }); + } } - menu.addAction("D&uplicate\tCtrl+D", [this, nodeId]() { + menu.addAction(icon("files.svg"), "D&uplicate\tCtrl+D", [this, nodeId]() { int ni = m_doc->tree.indexOfId(nodeId); if (ni >= 0) duplicateNode(ni); }); - menu.addAction("&Delete\tDelete", [this, nodeId]() { + menu.addAction(icon("trash.svg"), "&Delete\tDelete", [this, nodeId]() { int ni = m_doc->tree.indexOfId(nodeId); if (ni >= 0) removeNode(ni); }); menu.addSeparator(); - menu.addAction("Copy &Address", [this, nodeId]() { + menu.addAction(icon("link.svg"), "Copy &Address", [this, nodeId]() { int ni = m_doc->tree.indexOfId(nodeId); if (ni < 0) return; uint64_t addr = m_doc->tree.baseAddress + m_doc->tree.computeOffset(ni); @@ -859,7 +885,7 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx, QStringLiteral("0x") + QString::number(addr, 16).toUpper()); }); - menu.addAction("Copy &Offset", [this, nodeId]() { + menu.addAction(icon("whole-word.svg"), "Copy &Offset", [this, nodeId]() { int ni = m_doc->tree.indexOfId(nodeId); if (ni < 0) return; int off = m_doc->tree.nodes[ni].offset; @@ -867,7 +893,7 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx, QStringLiteral("+0x") + QString::number(off, 16).toUpper().rightJustified(4, '0')); }); - menu.addAction("Copy All as &Text", [editor]() { + menu.addAction(icon("clippy.svg"), "Copy All as &Text", [editor]() { QApplication::clipboard()->setText(editor->scintilla()->text()); }); diff --git a/src/resources.qrc b/src/resources.qrc index 195de70..320a0fa 100644 --- a/src/resources.qrc +++ b/src/resources.qrc @@ -31,5 +31,14 @@ vsicons/code.svg vsicons/export.svg vsicons/preview.svg + vsicons/trash.svg + vsicons/clippy.svg + vsicons/link.svg + vsicons/diff-added.svg + vsicons/expand-all.svg + vsicons/collapse-all.svg + vsicons/rename.svg + vsicons/whole-word.svg + vsicons/list-selection.svg diff --git a/tests/test_context_menu.cpp b/tests/test_context_menu.cpp new file mode 100644 index 0000000..98d68e6 --- /dev/null +++ b/tests/test_context_menu.cpp @@ -0,0 +1,400 @@ +#include +#include +#include +#include +#include "controller.h" +#include "core.h" + +using namespace rcx; + +static void buildTree(NodeTree& tree) { + tree.baseAddress = 0x1000; + + Node root; + root.kind = NodeKind::Struct; + root.structTypeName = "Player"; + root.name = "Player"; + root.parentId = 0; + root.offset = 0; + 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::Int32, "health"); + field(4, NodeKind::Int32, "armor"); + field(8, NodeKind::Float, "speed"); + field(12, NodeKind::Hex32, "flags"); +} + +static QByteArray makeBuffer() { + QByteArray data(128, '\0'); + int32_t health = 100; + memcpy(data.data() + 0, &health, 4); + int32_t armor = 50; + memcpy(data.data() + 4, &armor, 4); + float speed = 3.5f; + memcpy(data.data() + 8, &speed, 4); + uint32_t flags = 0xFF00FF00; + memcpy(data.data() + 12, &flags, 4); + return data; +} + +class TestContextMenu : public QObject { + Q_OBJECT +private: + RcxDocument* m_doc = nullptr; + RcxController* m_ctrl = nullptr; + QSplitter* m_splitter = nullptr; + RcxEditor* m_editor = nullptr; + + int findNode(const QString& name) const { + for (int i = 0; i < m_doc->tree.nodes.size(); i++) + if (m_doc->tree.nodes[i].name == name) return i; + return -1; + } + + int countNodes() const { return m_doc->tree.nodes.size(); } + +private slots: + void init() { + m_doc = new RcxDocument(); + buildTree(m_doc->tree); + m_doc->provider = std::make_unique(makeBuffer()); + + m_splitter = new QSplitter(); + 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 m_ctrl; m_ctrl = nullptr; + m_editor = nullptr; + delete m_splitter; m_splitter = nullptr; + delete m_doc; m_doc = nullptr; + } + + // ── Insert adds exactly one node ── + + void testInsertAddsOneNode() { + int before = countNodes(); + uint64_t rootId = m_doc->tree.nodes[0].id; + m_ctrl->insertNode(rootId, 16, NodeKind::Hex64, "inserted"); + QApplication::processEvents(); + + QCOMPARE(countNodes(), before + 1); + + int idx = findNode("inserted"); + QVERIFY(idx >= 0); + QCOMPARE(m_doc->tree.nodes[idx].kind, NodeKind::Hex64); + QCOMPARE(m_doc->tree.nodes[idx].offset, 16); + QCOMPARE(m_doc->tree.nodes[idx].parentId, rootId); + } + + // ── Insert at auto-offset places after last sibling ── + + void testInsertAutoOffset() { + uint64_t rootId = m_doc->tree.nodes[0].id; + + // Last child is "flags" at offset 12, size 4 → end = 16 + m_ctrl->insertNode(rootId, -1, NodeKind::Hex64, "autoPlaced"); + QApplication::processEvents(); + + int idx = findNode("autoPlaced"); + QVERIFY(idx >= 0); + // Hex64 is 8-byte aligned, next aligned offset after 16 is 16 + QCOMPARE(m_doc->tree.nodes[idx].offset, 16); + } + + // ── Duplicate creates exactly one copy ── + + void testDuplicateAddsOneNode() { + int flagsIdx = findNode("flags"); + QVERIFY(flagsIdx >= 0); + int before = countNodes(); + + m_ctrl->duplicateNode(flagsIdx); + QApplication::processEvents(); + + QCOMPARE(countNodes(), before + 1); + + int copyIdx = findNode("flags_copy"); + QVERIFY2(copyIdx >= 0, "Expected a node named 'flags_copy'"); + QCOMPARE(m_doc->tree.nodes[copyIdx].kind, NodeKind::Hex32); + QCOMPARE(m_doc->tree.nodes[copyIdx].offset, 16); // flags(12) + 4 = 16 + } + + // ── Duplicate preserves original node unchanged ── + + void testDuplicatePreservesOriginal() { + int flagsIdx = findNode("flags"); + QVERIFY(flagsIdx >= 0); + NodeKind origKind = m_doc->tree.nodes[flagsIdx].kind; + int origOffset = m_doc->tree.nodes[flagsIdx].offset; + QString origName = m_doc->tree.nodes[flagsIdx].name; + + m_ctrl->duplicateNode(flagsIdx); + QApplication::processEvents(); + + // Original should be unchanged (re-find in case index shifted) + flagsIdx = findNode("flags"); + QVERIFY(flagsIdx >= 0); + QCOMPARE(m_doc->tree.nodes[flagsIdx].kind, origKind); + QCOMPARE(m_doc->tree.nodes[flagsIdx].offset, origOffset); + QCOMPARE(m_doc->tree.nodes[flagsIdx].name, origName); + } + + // ── Duplicate undo removes the copy ── + + void testDuplicateUndo() { + int before = countNodes(); + int flagsIdx = findNode("flags"); + QVERIFY(flagsIdx >= 0); + + m_ctrl->duplicateNode(flagsIdx); + QApplication::processEvents(); + QCOMPARE(countNodes(), before + 1); + + m_doc->undoStack.undo(); + QApplication::processEvents(); + QCOMPARE(countNodes(), before); + QCOMPARE(findNode("flags_copy"), -1); + } + + // ── Duplicate on struct is no-op ── + + void testDuplicateStructNoOp() { + int rootIdx = findNode("Player"); + QVERIFY(rootIdx >= 0); + int before = countNodes(); + + m_ctrl->duplicateNode(rootIdx); + QApplication::processEvents(); + + QCOMPARE(countNodes(), before); + } + + // ── Insert at root level (parentId=0) ── + + void testInsertAtRootLevel() { + int before = countNodes(); + m_ctrl->insertNode(0, -1, NodeKind::Hex64, "rootField"); + QApplication::processEvents(); + + QCOMPARE(countNodes(), before + 1); + int idx = findNode("rootField"); + QVERIFY(idx >= 0); + QCOMPARE(m_doc->tree.nodes[idx].parentId, (uint64_t)0); + } + + // ── Append 128 bytes adds exactly 16 Hex64 nodes ── + + void testAppend128Bytes() { + int before = countNodes(); + + // Simulate what "Append 128 bytes" does + m_ctrl->document()->undoStack.beginMacro("Append 128 bytes"); + for (int i = 0; i < 16; i++) + m_ctrl->insertNode(0, -1, NodeKind::Hex64, + QStringLiteral("field_%1").arg(i)); + m_ctrl->document()->undoStack.endMacro(); + QApplication::processEvents(); + + QCOMPARE(countNodes(), before + 16); + + // All should be root-level Hex64 + int foundCount = 0; + for (int i = 0; i < m_doc->tree.nodes.size(); i++) { + const auto& n = m_doc->tree.nodes[i]; + if (n.name.startsWith("field_") && n.parentId == 0 + && n.kind == NodeKind::Hex64) { + foundCount++; + } + } + QCOMPARE(foundCount, 16); + } + + // ── Append 128 bytes undo removes all 16 at once ── + + void testAppend128BytesUndo() { + int before = countNodes(); + + m_ctrl->document()->undoStack.beginMacro("Append 128 bytes"); + for (int i = 0; i < 16; i++) + m_ctrl->insertNode(0, -1, NodeKind::Hex64, + QStringLiteral("field_%1").arg(i)); + m_ctrl->document()->undoStack.endMacro(); + QApplication::processEvents(); + QCOMPARE(countNodes(), before + 16); + + // Single undo undoes the entire macro + m_doc->undoStack.undo(); + QApplication::processEvents(); + QCOMPARE(countNodes(), before); + } + + // ── Insert child into struct ── + + void testInsertChildIntoStruct() { + uint64_t rootId = m_doc->tree.nodes[0].id; + int before = countNodes(); + + m_ctrl->insertNode(rootId, 0, NodeKind::Hex64, "childField"); + QApplication::processEvents(); + + QCOMPARE(countNodes(), before + 1); + int idx = findNode("childField"); + QVERIFY(idx >= 0); + QCOMPARE(m_doc->tree.nodes[idx].parentId, rootId); + QCOMPARE(m_doc->tree.nodes[idx].offset, 0); + } + + // ── Remove node then undo restores it ── + + void testRemoveAndUndoNode() { + int flagsIdx = findNode("flags"); + QVERIFY(flagsIdx >= 0); + int before = countNodes(); + + m_ctrl->removeNode(flagsIdx); + QApplication::processEvents(); + QCOMPARE(countNodes(), before - 1); + QCOMPARE(findNode("flags"), -1); + + m_doc->undoStack.undo(); + QApplication::processEvents(); + QCOMPARE(countNodes(), before); + QVERIFY(findNode("flags") >= 0); + } + + // ── Multiple duplicates each add exactly one ── + + void testMultipleDuplicates() { + int before = countNodes(); + int healthIdx = findNode("health"); + QVERIFY(healthIdx >= 0); + + m_ctrl->duplicateNode(healthIdx); + QApplication::processEvents(); + QCOMPARE(countNodes(), before + 1); + + int copyIdx = findNode("health_copy"); + QVERIFY(copyIdx >= 0); + + m_ctrl->duplicateNode(copyIdx); + QApplication::processEvents(); + QCOMPARE(countNodes(), before + 2); + + int copy2Idx = findNode("health_copy_copy"); + QVERIFY(copy2Idx >= 0); + } + + // ── Duplicate copy has correct parent ── + + void testDuplicateCopyParent() { + int healthIdx = findNode("health"); + QVERIFY(healthIdx >= 0); + uint64_t parentId = m_doc->tree.nodes[healthIdx].parentId; + + m_ctrl->duplicateNode(healthIdx); + QApplication::processEvents(); + + int copyIdx = findNode("health_copy"); + QVERIFY(copyIdx >= 0); + QCOMPARE(m_doc->tree.nodes[copyIdx].parentId, parentId); + } + + // ── Insert struct at root then add children ── + + void testInsertStructAndChildren() { + int before = countNodes(); + + m_ctrl->insertNode(0, -1, NodeKind::Struct, "NewClass"); + QApplication::processEvents(); + QCOMPARE(countNodes(), before + 1); + + int structIdx = findNode("NewClass"); + QVERIFY(structIdx >= 0); + uint64_t structId = m_doc->tree.nodes[structIdx].id; + + m_ctrl->insertNode(structId, 0, NodeKind::Int32, "x"); + m_ctrl->insertNode(structId, -1, NodeKind::Int32, "y"); + QApplication::processEvents(); + QCOMPARE(countNodes(), before + 3); + + int xIdx = findNode("x"); + int yIdx = findNode("y"); + QVERIFY(xIdx >= 0); + QVERIFY(yIdx >= 0); + QCOMPARE(m_doc->tree.nodes[xIdx].parentId, structId); + QCOMPARE(m_doc->tree.nodes[yIdx].parentId, structId); + } + + // ── Batch remove deletes multiple nodes ── + + void testBatchRemove() { + int healthIdx = findNode("health"); + int armorIdx = findNode("armor"); + QVERIFY(healthIdx >= 0); + QVERIFY(armorIdx >= 0); + int before = countNodes(); + + m_ctrl->batchRemoveNodes({healthIdx, armorIdx}); + QApplication::processEvents(); + QCOMPARE(countNodes(), before - 2); + QCOMPARE(findNode("health"), -1); + QCOMPARE(findNode("armor"), -1); + + // Undo restores both + m_doc->undoStack.undo(); + QApplication::processEvents(); + QCOMPARE(countNodes(), before); + QVERIFY(findNode("health") >= 0); + QVERIFY(findNode("armor") >= 0); + } + + // ── Insert with invalid parent still works (root-level) ── + + void testInsertInvalidParent() { + int before = countNodes(); + // parentId=999 doesn't exist, but insertNode doesn't validate parent + m_ctrl->insertNode(999, 0, NodeKind::Hex32, "orphan"); + QApplication::processEvents(); + QCOMPARE(countNodes(), before + 1); + } + + // ── Duplicate out-of-range index is no-op ── + + void testDuplicateInvalidIndex() { + int before = countNodes(); + m_ctrl->duplicateNode(-1); + m_ctrl->duplicateNode(9999); + QApplication::processEvents(); + QCOMPARE(countNodes(), before); + } + + // ── Remove out-of-range index is no-op ── + + void testRemoveInvalidIndex() { + int before = countNodes(); + m_ctrl->removeNode(-1); + m_ctrl->removeNode(9999); + QApplication::processEvents(); + QCOMPARE(countNodes(), before); + } +}; + +QTEST_MAIN(TestContextMenu) +#include "test_context_menu.moc"