Files
whetstone_DSL/editor/tests/step261_test.cpp
2026-02-11 20:27:47 +00:00

423 lines
16 KiB
C++

// Step 261 TDD Test: Project-Wide Search and Refactor
//
// Tests searchProject (find symbol references across files by name/nodeId),
// renameSymbol (rename across all files with preview and apply modes),
// and MCP tool registration.
//
// Note: Python parser creates FunctionCall nodes for calls in return
// statements and expressions, but not inside assignments. Test sources
// use return-based calls to ensure proper AST node creation.
#include "HeadlessEditorState.h"
#include "MCPServer.h"
#include <iostream>
#include <string>
#include <set>
static void expect(bool cond, const std::string& name,
int& passed, int& failed) {
if (cond) {
std::cout << "Test " << (passed + failed + 1)
<< " PASS: " << name << "\n";
++passed;
} else {
std::cout << "Test " << (passed + failed + 1)
<< " FAIL: " << name << "\n";
++failed;
}
}
static json rpc(HeadlessEditorState& state, const std::string& session,
const std::string& method,
json params = json::object()) {
json request = {{"jsonrpc", "2.0"}, {"id", 1},
{"method", method}, {"params", params}};
return state.processAgentRequest(request, session);
}
int main() {
int passed = 0;
int failed = 0;
// Module A: defines helper and compute; compute calls helper in return
std::string srcA =
"def helper(x):\n"
" return x * 2\n\n"
"def compute(a, b):\n"
" return helper(a) + b\n";
// Module B: defines process, calls helper in return (cross-file ref)
std::string srcB =
"def process(data):\n"
" return helper(data)\n";
// ---------------------------------------------------------------
// Test 1: searchProject by name — finds references across files
// ---------------------------------------------------------------
{
HeadlessEditorState state;
state.defaultLanguage = "python";
state.setAgentRole("s1", AgentRole::Refactor);
rpc(state, "s1", "openFile",
{{"path", "app.py"}, {"content", srcA}});
rpc(state, "s1", "openFile",
{{"path", "lib.py"}, {"content", srcB}});
json resp = rpc(state, "s1", "searchProject",
{{"name", "helper"}});
bool hasResult = resp.contains("result");
int count = hasResult ? resp["result"].value("count", 0) : 0;
int fileCount = hasResult
? resp["result"].value("fileCount", 0) : 0;
// app.py: function def + call in compute; lib.py: call in process
expect(hasResult && count >= 3 && fileCount == 2,
"searchProject 'helper': " + std::to_string(count) +
" refs across " + std::to_string(fileCount) + " files",
passed, failed);
}
// ---------------------------------------------------------------
// Test 2: searchProject returns correct fields
// ---------------------------------------------------------------
{
HeadlessEditorState state;
state.defaultLanguage = "python";
state.setAgentRole("s1", AgentRole::Refactor);
rpc(state, "s1", "openFile",
{{"path", "app.py"}, {"content", srcA}});
json resp = rpc(state, "s1", "searchProject",
{{"name", "helper"}});
bool correctFields = false;
if (resp.contains("result") &&
!resp["result"]["references"].empty()) {
const auto& ref = resp["result"]["references"][0];
correctFields = ref.contains("file") &&
ref.contains("line") &&
ref.contains("col") &&
ref.contains("nodeId") &&
ref.contains("kind") &&
ref.contains("context");
}
expect(correctFields,
"Search results have file, line, col, nodeId, kind, "
"context",
passed, failed);
}
// ---------------------------------------------------------------
// Test 3: searchProject by nodeId
// ---------------------------------------------------------------
{
HeadlessEditorState state;
state.defaultLanguage = "python";
state.setAgentRole("s1", AgentRole::Refactor);
rpc(state, "s1", "openFile",
{{"path", "app.py"}, {"content", srcA}});
rpc(state, "s1", "openFile",
{{"path", "lib.py"}, {"content", srcB}});
rpc(state, "s1", "setActiveBuffer", {{"path", "app.py"}});
// Find the helper function node ID
std::string helperId;
for (auto* c : state.activeAST()->allChildren()) {
if (c->conceptType == "Function" &&
getNodeName(c) == "helper") {
helperId = c->id;
break;
}
}
json resp = rpc(state, "s1", "searchProject",
{{"nodeId", helperId}});
int count = resp["result"].value("count", 0);
std::string resolvedName = resp["result"].value("name", "");
expect(count >= 3 && resolvedName == "helper",
"searchProject by nodeId resolves to 'helper', " +
std::to_string(count) + " refs",
passed, failed);
}
// ---------------------------------------------------------------
// Test 4: searchProject distinguishes definition vs call
// ---------------------------------------------------------------
{
HeadlessEditorState state;
state.defaultLanguage = "python";
state.setAgentRole("s1", AgentRole::Refactor);
rpc(state, "s1", "openFile",
{{"path", "app.py"}, {"content", srcA}});
json resp = rpc(state, "s1", "searchProject",
{{"name", "helper"}});
bool hasDef = false;
bool hasCall = false;
for (const auto& ref : resp["result"]["references"]) {
std::string kind = ref.value("kind", "");
if (kind == "definition") hasDef = true;
if (kind == "call") hasCall = true;
}
expect(hasDef && hasCall,
"Search distinguishes definition and call kinds",
passed, failed);
}
// ---------------------------------------------------------------
// Test 5: renameSymbol preview mode
// ---------------------------------------------------------------
{
HeadlessEditorState state;
state.defaultLanguage = "python";
state.setAgentRole("s1", AgentRole::Refactor);
rpc(state, "s1", "openFile",
{{"path", "app.py"}, {"content", srcA}});
rpc(state, "s1", "openFile",
{{"path", "lib.py"}, {"content", srcB}});
json resp = rpc(state, "s1", "renameSymbol",
{{"oldName", "helper"},
{"newName", "assist"},
{"preview", true}});
bool isPreview = resp["result"].value("preview", false);
int changeCount = resp["result"].value("changeCount", 0);
bool hasChanges = resp["result"].contains("changes") &&
resp["result"]["changes"].is_array();
// Preview should NOT actually rename
rpc(state, "s1", "setActiveBuffer", {{"path", "app.py"}});
bool notRenamed = false;
for (auto* c : state.activeAST()->allChildren()) {
if (c->conceptType == "Function" &&
getNodeName(c) == "helper") {
notRenamed = true;
break;
}
}
expect(isPreview && hasChanges && changeCount >= 3 && notRenamed,
"Preview: " + std::to_string(changeCount) +
" changes, NOT applied",
passed, failed);
}
// ---------------------------------------------------------------
// Test 6: renameSymbol apply mode — function renamed across files
// ---------------------------------------------------------------
{
HeadlessEditorState state;
state.defaultLanguage = "python";
state.setAgentRole("s1", AgentRole::Refactor);
rpc(state, "s1", "openFile",
{{"path", "app.py"}, {"content", srcA}});
rpc(state, "s1", "openFile",
{{"path", "lib.py"}, {"content", srcB}});
json resp = rpc(state, "s1", "renameSymbol",
{{"oldName", "helper"},
{"newName", "assist"}});
int applied = resp["result"].value("applied", 0);
int fileCount = resp["result"].value("fileCount", 0);
// Verify rename took effect in app.py (walk full tree)
rpc(state, "s1", "setActiveBuffer", {{"path", "app.py"}});
bool defRenamed = false;
bool callRenamed = false;
std::function<void(ASTNode*)> walkA = [&](ASTNode* n) {
if (!n) return;
if (n->conceptType == "Function" &&
getNodeName(n) == "assist")
defRenamed = true;
if (n->conceptType == "FunctionCall" &&
getNodeName(n) == "assist")
callRenamed = true;
for (auto* c : n->allChildren()) walkA(c);
};
walkA(state.activeAST());
// Verify rename took effect in lib.py
rpc(state, "s1", "setActiveBuffer", {{"path", "lib.py"}});
bool libCallRenamed = false;
std::function<void(ASTNode*)> walkB = [&](ASTNode* n) {
if (!n) return;
if (n->conceptType == "FunctionCall" &&
getNodeName(n) == "assist")
libCallRenamed = true;
for (auto* c : n->allChildren()) walkB(c);
};
walkB(state.activeAST());
expect(applied >= 3 && fileCount == 2 &&
defRenamed && callRenamed && libCallRenamed,
"Rename applied: " + std::to_string(applied) +
" changes across " + std::to_string(fileCount) +
" files, verified in both ASTs",
passed, failed);
}
// ---------------------------------------------------------------
// Test 7: renameSymbol — parameter and variable reference rename
// ---------------------------------------------------------------
{
HeadlessEditorState state;
state.defaultLanguage = "python";
state.setAgentRole("s1", AgentRole::Refactor);
// Use parameter (properly parsed) and VariableReference in return
std::string src =
"def foo(result):\n"
" return result + 1\n";
rpc(state, "s1", "openFile",
{{"path", "var.py"}, {"content", src}});
json resp = rpc(state, "s1", "renameSymbol",
{{"oldName", "result"},
{"newName", "output"}});
int applied = resp["result"].value("applied", 0);
// Verify: parameter and reference both renamed (walk tree)
rpc(state, "s1", "setActiveBuffer", {{"path", "var.py"}});
bool paramRenamed = false;
bool refRenamed = false;
std::function<void(ASTNode*)> walk = [&](ASTNode* n) {
if (!n) return;
if (n->conceptType == "Parameter" &&
getNodeName(n) == "output")
paramRenamed = true;
if (n->conceptType == "VariableReference" &&
getNodeName(n) == "output")
refRenamed = true;
for (auto* c : n->allChildren()) walk(c);
};
walk(state.activeAST());
expect(applied >= 2 && paramRenamed && refRenamed,
"Param/ref rename: " + std::to_string(applied) +
" changes, param+ref both updated",
passed, failed);
}
// ---------------------------------------------------------------
// Test 8: renameSymbol — no matches returns error
// ---------------------------------------------------------------
{
HeadlessEditorState state;
state.defaultLanguage = "python";
state.setAgentRole("s1", AgentRole::Refactor);
rpc(state, "s1", "openFile",
{{"path", "app.py"}, {"content", srcA}});
json resp = rpc(state, "s1", "renameSymbol",
{{"oldName", "nonexistent"},
{"newName", "something"}});
bool hasError = resp.contains("error");
expect(hasError,
"Rename of nonexistent symbol returns error",
passed, failed);
}
// ---------------------------------------------------------------
// Test 9: Linter role can search but not rename
// ---------------------------------------------------------------
{
HeadlessEditorState state;
state.defaultLanguage = "python";
state.setAgentRole("admin", AgentRole::Refactor);
state.setAgentRole("linter", AgentRole::Linter);
rpc(state, "admin", "openFile",
{{"path", "app.py"}, {"content", srcA}});
// Linter can search
json searchResp = rpc(state, "linter", "searchProject",
{{"name", "helper"}});
bool searchOk = searchResp.contains("result");
// Linter cannot rename
json renameResp = rpc(state, "linter", "renameSymbol",
{{"oldName", "helper"},
{"newName", "assist"}});
bool renameDenied = renameResp.contains("error");
expect(searchOk && renameDenied,
"Linter can search but cannot rename",
passed, failed);
}
// ---------------------------------------------------------------
// Test 10: searchProject — no results for unknown name
// ---------------------------------------------------------------
{
HeadlessEditorState state;
state.defaultLanguage = "python";
state.setAgentRole("s1", AgentRole::Refactor);
rpc(state, "s1", "openFile",
{{"path", "app.py"}, {"content", srcA}});
json resp = rpc(state, "s1", "searchProject",
{{"name", "zzz_not_found"}});
int count = resp["result"].value("count", 0);
expect(count == 0,
"Search for unknown name returns 0 references",
passed, failed);
}
// ---------------------------------------------------------------
// Test 11: MCP tools registered
// ---------------------------------------------------------------
{
MCPServer mcp;
bool foundSearch = false;
bool foundRename = false;
for (const auto& t : mcp.getTools()) {
if (t.name == "whetstone_search_project")
foundSearch = true;
if (t.name == "whetstone_rename_symbol")
foundRename = true;
}
expect(foundSearch && foundRename,
"whetstone_search_project and whetstone_rename_symbol "
"MCP tools registered",
passed, failed);
}
// ---------------------------------------------------------------
// Test 12: renameSymbol marks buffers as modified
// ---------------------------------------------------------------
{
HeadlessEditorState state;
state.defaultLanguage = "python";
state.setAgentRole("s1", AgentRole::Refactor);
rpc(state, "s1", "openFile",
{{"path", "app.py"}, {"content", srcA}});
rpc(state, "s1", "openFile",
{{"path", "lib.py"}, {"content", srcB}});
// Reset modified flags
for (auto& [path, buf] : state.bufferStates)
buf->modified = false;
rpc(state, "s1", "renameSymbol",
{{"oldName", "helper"}, {"newName", "assist"}});
bool appModified = false;
bool libModified = false;
for (const auto& [path, buf] : state.bufferStates) {
if (path == "app.py" && buf->modified) appModified = true;
if (path == "lib.py" && buf->modified) libModified = true;
}
expect(appModified && libModified,
"Rename marks both app.py and lib.py as modified",
passed, failed);
}
std::cout << "\n=== Step 261 Results: " << passed << " passed, "
<< failed << " failed ===\n";
return failed == 0 ? 0 : 1;
}