feat: Implement Proposal Saving and GUI Automation Enhancements
- Added functionality to save AI agent plans to disk using ProposalRegistry, including directory creation and error handling for failed saves. - Enhanced AIGUIController with new gRPC GUI automation actions, including click, type, wait, and verify actions, improving interaction capabilities. - Introduced new command parsing for set-area and replace-tile commands in Tile16ProposalGenerator, allowing for more complex tile modifications. - Added integration and unit tests for AIGUIController and Tile16ProposalGenerator to ensure robust functionality and error handling.
This commit is contained in:
@@ -261,10 +261,23 @@ absl::Status HandlePlanCommand(const std::vector<std::string>& arg_vec) {
|
|||||||
}
|
}
|
||||||
auto proposal = proposal_or.value();
|
auto proposal = proposal_or.value();
|
||||||
|
|
||||||
// TODO: Save the proposal to disk using ProposalRegistry
|
auto& registry = ProposalRegistry::Instance();
|
||||||
// For now, just print it.
|
auto plans_dir = registry.RootDirectory() / "plans";
|
||||||
|
std::error_code ec;
|
||||||
|
std::filesystem::create_directories(plans_dir, ec);
|
||||||
|
if (ec) {
|
||||||
|
return absl::InternalError(absl::StrCat("Failed to create plans directory: ", ec.message()));
|
||||||
|
}
|
||||||
|
|
||||||
|
auto plan_path = plans_dir / (proposal.id + ".json");
|
||||||
|
auto save_status = generator.SaveProposal(proposal, plan_path.string());
|
||||||
|
if (!save_status.ok()) {
|
||||||
|
return save_status;
|
||||||
|
}
|
||||||
|
|
||||||
std::cout << "AI Agent Plan (Proposal ID: " << proposal.id << "):\n";
|
std::cout << "AI Agent Plan (Proposal ID: " << proposal.id << "):\n";
|
||||||
std::cout << proposal.ToJson() << std::endl;
|
std::cout << proposal.ToJson() << std::endl;
|
||||||
|
std::cout << "\n✅ Plan saved to: " << plan_path.string() << std::endl;
|
||||||
|
|
||||||
return absl::OkStatus();
|
return absl::OkStatus();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
|
|
||||||
|
#include "absl/strings/str_cat.h"
|
||||||
#include "absl/strings/str_format.h"
|
#include "absl/strings/str_format.h"
|
||||||
#include "absl/time/clock.h"
|
#include "absl/time/clock.h"
|
||||||
#include "absl/time/time.h"
|
#include "absl/time/time.h"
|
||||||
@@ -18,7 +19,7 @@ namespace cli {
|
|||||||
namespace ai {
|
namespace ai {
|
||||||
|
|
||||||
AIGUIController::AIGUIController(GeminiAIService* gemini_service,
|
AIGUIController::AIGUIController(GeminiAIService* gemini_service,
|
||||||
gui::GuiAutomationClient* gui_client)
|
GuiAutomationClient* gui_client)
|
||||||
: gemini_service_(gemini_service),
|
: gemini_service_(gemini_service),
|
||||||
gui_client_(gui_client),
|
gui_client_(gui_client),
|
||||||
vision_refiner_(std::make_unique<VisionActionRefiner>(gemini_service)) {
|
vision_refiner_(std::make_unique<VisionActionRefiner>(gemini_service)) {
|
||||||
@@ -244,11 +245,174 @@ absl::Status AIGUIController::ExecuteGRPCAction(const AIAction& action) {
|
|||||||
return test_script_result.status();
|
return test_script_result.status();
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement gRPC GUI automation when GuiAutomationClient is ready
|
#ifdef YAZE_WITH_GRPC
|
||||||
// For now, just log the generated test script
|
if (!gui_client_) {
|
||||||
|
return absl::FailedPreconditionError("GUI automation client not initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the action based on its type
|
||||||
|
if (action.type == AIActionType::kClickButton) {
|
||||||
|
// Extract target from parameters
|
||||||
|
std::string target = "button:Unknown";
|
||||||
|
if (action.parameters.count("target") > 0) {
|
||||||
|
target = action.parameters.at("target");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine click type
|
||||||
|
ClickType click_type = ClickType::kLeft;
|
||||||
|
if (action.parameters.count("click_type") > 0) {
|
||||||
|
const std::string& type = action.parameters.at("click_type");
|
||||||
|
if (type == "right") {
|
||||||
|
click_type = ClickType::kRight;
|
||||||
|
} else if (type == "middle") {
|
||||||
|
click_type = ClickType::kMiddle;
|
||||||
|
} else if (type == "double") {
|
||||||
|
click_type = ClickType::kDouble;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto result = gui_client_->Click(target, click_type);
|
||||||
|
if (!result.ok()) {
|
||||||
|
return result.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result->success) {
|
||||||
|
return absl::InternalError(
|
||||||
|
absl::StrCat("Click action failed: ", result->message));
|
||||||
|
}
|
||||||
|
|
||||||
|
return absl::OkStatus();
|
||||||
|
}
|
||||||
|
else if (action.type == AIActionType::kSelectTile) {
|
||||||
|
// Extract target and text from parameters (treating select as a type-like action)
|
||||||
|
std::string target = "input:Unknown";
|
||||||
|
std::string text = "";
|
||||||
|
bool clear_first = true;
|
||||||
|
|
||||||
|
if (action.parameters.count("target") > 0) {
|
||||||
|
target = action.parameters.at("target");
|
||||||
|
}
|
||||||
|
if (action.parameters.count("text") > 0) {
|
||||||
|
text = action.parameters.at("text");
|
||||||
|
}
|
||||||
|
if (action.parameters.count("clear_first") > 0) {
|
||||||
|
clear_first = (action.parameters.at("clear_first") == "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
auto result = gui_client_->Type(target, text, clear_first);
|
||||||
|
if (!result.ok()) {
|
||||||
|
return result.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result->success) {
|
||||||
|
return absl::InternalError(
|
||||||
|
absl::StrCat("Type action failed: ", result->message));
|
||||||
|
}
|
||||||
|
|
||||||
|
return absl::OkStatus();
|
||||||
|
}
|
||||||
|
else if (action.type == AIActionType::kWait) {
|
||||||
|
// Extract condition and timeout from parameters
|
||||||
|
std::string condition = "visible";
|
||||||
|
int timeout_ms = 5000;
|
||||||
|
int poll_interval_ms = 100;
|
||||||
|
|
||||||
|
if (action.parameters.count("condition") > 0) {
|
||||||
|
condition = action.parameters.at("condition");
|
||||||
|
}
|
||||||
|
if (action.parameters.count("timeout_ms") > 0) {
|
||||||
|
timeout_ms = std::stoi(action.parameters.at("timeout_ms"));
|
||||||
|
}
|
||||||
|
if (action.parameters.count("poll_interval_ms") > 0) {
|
||||||
|
poll_interval_ms = std::stoi(action.parameters.at("poll_interval_ms"));
|
||||||
|
}
|
||||||
|
|
||||||
|
auto result = gui_client_->Wait(condition, timeout_ms, poll_interval_ms);
|
||||||
|
if (!result.ok()) {
|
||||||
|
return result.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result->success) {
|
||||||
|
return absl::InternalError(
|
||||||
|
absl::StrCat("Wait action failed: ", result->message));
|
||||||
|
}
|
||||||
|
|
||||||
|
return absl::OkStatus();
|
||||||
|
}
|
||||||
|
else if (action.type == AIActionType::kVerifyTile) {
|
||||||
|
// Extract condition from parameters (treating verify as assert)
|
||||||
|
std::string condition = "";
|
||||||
|
if (action.parameters.count("condition") > 0) {
|
||||||
|
condition = action.parameters.at("condition");
|
||||||
|
}
|
||||||
|
|
||||||
|
auto result = gui_client_->Assert(condition);
|
||||||
|
if (!result.ok()) {
|
||||||
|
return result.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result->success) {
|
||||||
|
return absl::InternalError(
|
||||||
|
absl::StrCat("Assert action failed: ", result->message,
|
||||||
|
" (expected: ", result->expected_value,
|
||||||
|
", actual: ", result->actual_value, ")"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return absl::OkStatus();
|
||||||
|
}
|
||||||
|
else if (action.type == AIActionType::kPlaceTile) {
|
||||||
|
// This is a special action for setting overworld tiles
|
||||||
|
// Extract map_id, x, y, tile from parameters
|
||||||
|
if (action.parameters.count("map_id") == 0 ||
|
||||||
|
action.parameters.count("x") == 0 ||
|
||||||
|
action.parameters.count("y") == 0 ||
|
||||||
|
action.parameters.count("tile") == 0) {
|
||||||
|
return absl::InvalidArgumentError(
|
||||||
|
"set_tile action requires map_id, x, y, and tile parameters");
|
||||||
|
}
|
||||||
|
|
||||||
|
int map_id = std::stoi(action.parameters.at("map_id"));
|
||||||
|
int x = std::stoi(action.parameters.at("x"));
|
||||||
|
int y = std::stoi(action.parameters.at("y"));
|
||||||
|
std::string tile_str = action.parameters.at("tile");
|
||||||
|
|
||||||
|
// Navigate to overworld editor
|
||||||
|
auto click_result = gui_client_->Click("menu:Overworld", ClickType::kLeft);
|
||||||
|
if (!click_result.ok() || !click_result->success) {
|
||||||
|
return absl::InternalError("Failed to open Overworld editor");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for overworld editor to be visible
|
||||||
|
auto wait_result = gui_client_->Wait("window:Overworld Editor", 2000, 100);
|
||||||
|
if (!wait_result.ok() || !wait_result->success) {
|
||||||
|
return absl::InternalError("Overworld editor did not appear");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the map ID
|
||||||
|
auto type_result = gui_client_->Type("input:Map ID", std::to_string(map_id), true);
|
||||||
|
if (!type_result.ok() || !type_result->success) {
|
||||||
|
return absl::InternalError("Failed to set map ID");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click on the tile position (approximate based on editor layout)
|
||||||
|
// This is a simplified implementation
|
||||||
|
std::string target = absl::StrCat("canvas:overworld@", x * 16, ",", y * 16);
|
||||||
|
click_result = gui_client_->Click(target, ClickType::kLeft);
|
||||||
|
if (!click_result.ok() || !click_result->success) {
|
||||||
|
return absl::InternalError("Failed to click tile position");
|
||||||
|
}
|
||||||
|
|
||||||
|
return absl::OkStatus();
|
||||||
|
}
|
||||||
|
else {
|
||||||
return absl::UnimplementedError(
|
return absl::UnimplementedError(
|
||||||
"gRPC GUI automation not yet fully implemented. "
|
absl::StrCat("Action type not implemented: ",
|
||||||
"GuiAutomationClient integration pending.");
|
static_cast<int>(action.type)));
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
return absl::UnimplementedError(
|
||||||
|
"gRPC GUI automation requires building with -DYAZE_WITH_GRPC=ON");
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,7 @@ namespace cli {
|
|||||||
|
|
||||||
// Forward declares
|
// Forward declares
|
||||||
class GeminiAIService;
|
class GeminiAIService;
|
||||||
namespace gui {
|
class GuiAutomationClient; // In cli namespace, not cli::gui
|
||||||
class GuiAutomationClient;
|
|
||||||
}
|
|
||||||
|
|
||||||
namespace ai {
|
namespace ai {
|
||||||
|
|
||||||
@@ -87,7 +85,7 @@ class AIGUIController {
|
|||||||
* @param gui_client gRPC client for GUI automation
|
* @param gui_client gRPC client for GUI automation
|
||||||
*/
|
*/
|
||||||
AIGUIController(GeminiAIService* gemini_service,
|
AIGUIController(GeminiAIService* gemini_service,
|
||||||
gui::GuiAutomationClient* gui_client);
|
GuiAutomationClient* gui_client);
|
||||||
|
|
||||||
~AIGUIController() = default;
|
~AIGUIController() = default;
|
||||||
|
|
||||||
@@ -141,7 +139,7 @@ class AIGUIController {
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
GeminiAIService* gemini_service_; // Not owned
|
GeminiAIService* gemini_service_; // Not owned
|
||||||
gui::GuiAutomationClient* gui_client_; // Not owned
|
GuiAutomationClient* gui_client_; // Not owned
|
||||||
std::unique_ptr<VisionActionRefiner> vision_refiner_;
|
std::unique_ptr<VisionActionRefiner> vision_refiner_;
|
||||||
gui::GuiActionGenerator action_generator_;
|
gui::GuiActionGenerator action_generator_;
|
||||||
ControlLoopConfig config_;
|
ControlLoopConfig config_;
|
||||||
|
|||||||
@@ -299,11 +299,12 @@ absl::StatusOr<AgentResponse> GeminiAIService::GenerateResponse(
|
|||||||
return absl::UnimplementedError(
|
return absl::UnimplementedError(
|
||||||
"Gemini AI service requires JSON support. Build with -DYAZE_WITH_JSON=ON");
|
"Gemini AI service requires JSON support. Build with -DYAZE_WITH_JSON=ON");
|
||||||
#else
|
#else
|
||||||
// TODO: Implement history-aware prompting.
|
|
||||||
if (history.empty()) {
|
if (history.empty()) {
|
||||||
return absl::InvalidArgumentError("History cannot be empty.");
|
return absl::InvalidArgumentError("History cannot be empty.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build a structured conversation history for better context
|
||||||
|
// Gemini supports multi-turn conversations via the contents array
|
||||||
std::string prompt = prompt_builder_.BuildPromptFromHistory(history);
|
std::string prompt = prompt_builder_.BuildPromptFromHistory(history);
|
||||||
|
|
||||||
// Skip availability check - causes segfault with current SSL setup
|
// Skip availability check - causes segfault with current SSL setup
|
||||||
@@ -319,6 +320,40 @@ absl::StatusOr<AgentResponse> GeminiAIService::GenerateResponse(
|
|||||||
try {
|
try {
|
||||||
if (config_.verbose) {
|
if (config_.verbose) {
|
||||||
std::cerr << "[DEBUG] Using curl for HTTPS request" << std::endl;
|
std::cerr << "[DEBUG] Using curl for HTTPS request" << std::endl;
|
||||||
|
std::cerr << "[DEBUG] Processing " << history.size() << " messages in history" << std::endl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build conversation history for multi-turn context
|
||||||
|
// Gemini supports alternating user/model messages for better context
|
||||||
|
nlohmann::json contents = nlohmann::json::array();
|
||||||
|
|
||||||
|
// Add conversation history (up to last 10 messages for context window)
|
||||||
|
int start_idx = std::max(0, static_cast<int>(history.size()) - 10);
|
||||||
|
for (size_t i = start_idx; i < history.size(); ++i) {
|
||||||
|
const auto& msg = history[i];
|
||||||
|
std::string role = (msg.sender == agent::ChatMessage::Sender::kUser) ? "user" : "model";
|
||||||
|
|
||||||
|
nlohmann::json message = {
|
||||||
|
{"role", role},
|
||||||
|
{"parts", {{
|
||||||
|
{"text", msg.message}
|
||||||
|
}}}
|
||||||
|
};
|
||||||
|
contents.push_back(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the last message is from the model, we need to ensure the conversation
|
||||||
|
// ends with a user message for Gemini
|
||||||
|
if (!history.empty() &&
|
||||||
|
history.back().sender == agent::ChatMessage::Sender::kAgent) {
|
||||||
|
// Add a continuation prompt
|
||||||
|
nlohmann::json user_continuation = {
|
||||||
|
{"role", "user"},
|
||||||
|
{"parts", {{
|
||||||
|
{"text", "Please continue or clarify your response."}
|
||||||
|
}}}
|
||||||
|
};
|
||||||
|
contents.push_back(user_continuation);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build request with proper Gemini API v1beta format
|
// Build request with proper Gemini API v1beta format
|
||||||
@@ -328,17 +363,17 @@ absl::StatusOr<AgentResponse> GeminiAIService::GenerateResponse(
|
|||||||
{"text", config_.system_instruction}
|
{"text", config_.system_instruction}
|
||||||
}}
|
}}
|
||||||
}},
|
}},
|
||||||
{"contents", {{
|
{"contents", contents},
|
||||||
{"parts", {{
|
|
||||||
{"text", prompt}
|
|
||||||
}}}
|
|
||||||
}}},
|
|
||||||
{"generationConfig", {
|
{"generationConfig", {
|
||||||
{"temperature", config_.temperature},
|
{"temperature", config_.temperature},
|
||||||
{"maxOutputTokens", config_.max_output_tokens}
|
{"maxOutputTokens", config_.max_output_tokens}
|
||||||
}}
|
}}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (config_.verbose) {
|
||||||
|
std::cerr << "[DEBUG] Sending " << contents.size() << " conversation turns to Gemini" << std::endl;
|
||||||
|
}
|
||||||
|
|
||||||
// Only add responseMimeType if NOT using function calling
|
// Only add responseMimeType if NOT using function calling
|
||||||
// (Gemini doesn't support both at the same time)
|
// (Gemini doesn't support both at the same time)
|
||||||
if (!function_calling_enabled_) {
|
if (!function_calling_enabled_) {
|
||||||
|
|||||||
@@ -302,6 +302,189 @@ absl::StatusOr<Tile16Change> Tile16ProposalGenerator::ParseSetTileCommand(
|
|||||||
return change;
|
return change;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
absl::StatusOr<std::vector<Tile16Change>> Tile16ProposalGenerator::ParseSetAreaCommand(
|
||||||
|
const std::string& command,
|
||||||
|
Rom* rom) {
|
||||||
|
|
||||||
|
// Expected format: "overworld set-area --map 0 --x 10 --y 20 --width 5 --height 3 --tile 0x02E"
|
||||||
|
std::vector<std::string> parts = absl::StrSplit(command, ' ');
|
||||||
|
|
||||||
|
if (parts.size() < 12) {
|
||||||
|
return absl::InvalidArgumentError(
|
||||||
|
absl::StrCat("Invalid set-area command format: ", command));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts[0] != "overworld" || parts[1] != "set-area") {
|
||||||
|
return absl::InvalidArgumentError(
|
||||||
|
absl::StrCat("Not a set-area command: ", command));
|
||||||
|
}
|
||||||
|
|
||||||
|
int map_id = 0, x = 0, y = 0, width = 1, height = 1;
|
||||||
|
uint16_t new_tile = 0;
|
||||||
|
|
||||||
|
// Parse arguments
|
||||||
|
for (size_t i = 2; i < parts.size(); i += 2) {
|
||||||
|
if (i + 1 >= parts.size()) break;
|
||||||
|
|
||||||
|
const std::string& flag = parts[i];
|
||||||
|
const std::string& value = parts[i + 1];
|
||||||
|
|
||||||
|
if (flag == "--map") {
|
||||||
|
map_id = std::stoi(value);
|
||||||
|
} else if (flag == "--x") {
|
||||||
|
x = std::stoi(value);
|
||||||
|
} else if (flag == "--y") {
|
||||||
|
y = std::stoi(value);
|
||||||
|
} else if (flag == "--width") {
|
||||||
|
width = std::stoi(value);
|
||||||
|
} else if (flag == "--height") {
|
||||||
|
height = std::stoi(value);
|
||||||
|
} else if (flag == "--tile") {
|
||||||
|
new_tile = static_cast<uint16_t>(std::stoi(value, nullptr, 16));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the ROM to get the old tile values
|
||||||
|
std::vector<Tile16Change> changes;
|
||||||
|
if (rom && rom->is_loaded()) {
|
||||||
|
zelda3::Overworld overworld(rom);
|
||||||
|
auto status = overworld.Load(rom);
|
||||||
|
if (!status.ok()) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the correct world based on map_id
|
||||||
|
if (map_id < 0x40) {
|
||||||
|
overworld.set_current_world(0); // Light World
|
||||||
|
} else if (map_id < 0x80) {
|
||||||
|
overworld.set_current_world(1); // Dark World
|
||||||
|
} else {
|
||||||
|
overworld.set_current_world(2); // Special World
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate changes for each tile in the area
|
||||||
|
for (int dy = 0; dy < height; ++dy) {
|
||||||
|
for (int dx = 0; dx < width; ++dx) {
|
||||||
|
Tile16Change change;
|
||||||
|
change.map_id = map_id;
|
||||||
|
change.x = x + dx;
|
||||||
|
change.y = y + dy;
|
||||||
|
change.new_tile = new_tile;
|
||||||
|
change.old_tile = overworld.GetTile(change.x, change.y);
|
||||||
|
changes.push_back(change);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If ROM not loaded, just create changes with unknown old values
|
||||||
|
for (int dy = 0; dy < height; ++dy) {
|
||||||
|
for (int dx = 0; dx < width; ++dx) {
|
||||||
|
Tile16Change change;
|
||||||
|
change.map_id = map_id;
|
||||||
|
change.x = x + dx;
|
||||||
|
change.y = y + dy;
|
||||||
|
change.new_tile = new_tile;
|
||||||
|
change.old_tile = 0x0000; // Unknown
|
||||||
|
changes.push_back(change);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
absl::StatusOr<std::vector<Tile16Change>> Tile16ProposalGenerator::ParseReplaceTileCommand(
|
||||||
|
const std::string& command,
|
||||||
|
Rom* rom) {
|
||||||
|
|
||||||
|
// Expected format: "overworld replace-tile --map 0 --old-tile 0x02E --new-tile 0x030"
|
||||||
|
// Optional bounds: --x-min 0 --y-min 0 --x-max 31 --y-max 31
|
||||||
|
std::vector<std::string> parts = absl::StrSplit(command, ' ');
|
||||||
|
|
||||||
|
if (parts.size() < 8) {
|
||||||
|
return absl::InvalidArgumentError(
|
||||||
|
absl::StrCat("Invalid replace-tile command format: ", command));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts[0] != "overworld" || parts[1] != "replace-tile") {
|
||||||
|
return absl::InvalidArgumentError(
|
||||||
|
absl::StrCat("Not a replace-tile command: ", command));
|
||||||
|
}
|
||||||
|
|
||||||
|
int map_id = 0;
|
||||||
|
uint16_t old_tile = 0, new_tile = 0;
|
||||||
|
int x_min = 0, y_min = 0, x_max = 31, y_max = 31;
|
||||||
|
|
||||||
|
// Parse arguments
|
||||||
|
for (size_t i = 2; i < parts.size(); i += 2) {
|
||||||
|
if (i + 1 >= parts.size()) break;
|
||||||
|
|
||||||
|
const std::string& flag = parts[i];
|
||||||
|
const std::string& value = parts[i + 1];
|
||||||
|
|
||||||
|
if (flag == "--map") {
|
||||||
|
map_id = std::stoi(value);
|
||||||
|
} else if (flag == "--old-tile") {
|
||||||
|
old_tile = static_cast<uint16_t>(std::stoi(value, nullptr, 16));
|
||||||
|
} else if (flag == "--new-tile") {
|
||||||
|
new_tile = static_cast<uint16_t>(std::stoi(value, nullptr, 16));
|
||||||
|
} else if (flag == "--x-min") {
|
||||||
|
x_min = std::stoi(value);
|
||||||
|
} else if (flag == "--y-min") {
|
||||||
|
y_min = std::stoi(value);
|
||||||
|
} else if (flag == "--x-max") {
|
||||||
|
x_max = std::stoi(value);
|
||||||
|
} else if (flag == "--y-max") {
|
||||||
|
y_max = std::stoi(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rom || !rom->is_loaded()) {
|
||||||
|
return absl::FailedPreconditionError(
|
||||||
|
"ROM must be loaded to scan for tiles to replace");
|
||||||
|
}
|
||||||
|
|
||||||
|
zelda3::Overworld overworld(rom);
|
||||||
|
auto status = overworld.Load(rom);
|
||||||
|
if (!status.ok()) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the correct world based on map_id
|
||||||
|
if (map_id < 0x40) {
|
||||||
|
overworld.set_current_world(0); // Light World
|
||||||
|
} else if (map_id < 0x80) {
|
||||||
|
overworld.set_current_world(1); // Dark World
|
||||||
|
} else {
|
||||||
|
overworld.set_current_world(2); // Special World
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan the specified area for tiles to replace
|
||||||
|
std::vector<Tile16Change> changes;
|
||||||
|
for (int y = y_min; y <= y_max; ++y) {
|
||||||
|
for (int x = x_min; x <= x_max; ++x) {
|
||||||
|
uint16_t current_tile = overworld.GetTile(x, y);
|
||||||
|
if (current_tile == old_tile) {
|
||||||
|
Tile16Change change;
|
||||||
|
change.map_id = map_id;
|
||||||
|
change.x = x;
|
||||||
|
change.y = y;
|
||||||
|
change.old_tile = old_tile;
|
||||||
|
change.new_tile = new_tile;
|
||||||
|
changes.push_back(change);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changes.empty()) {
|
||||||
|
std::ostringstream oss;
|
||||||
|
oss << "0x" << std::hex << old_tile;
|
||||||
|
return absl::NotFoundError(
|
||||||
|
absl::StrCat("No tiles matching ", oss.str(), " found in specified area"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
absl::StatusOr<Tile16Proposal> Tile16ProposalGenerator::GenerateFromCommands(
|
absl::StatusOr<Tile16Proposal> Tile16ProposalGenerator::GenerateFromCommands(
|
||||||
const std::string& prompt,
|
const std::string& prompt,
|
||||||
const std::vector<std::string>& commands,
|
const std::vector<std::string>& commands,
|
||||||
@@ -322,7 +505,7 @@ absl::StatusOr<Tile16Proposal> Tile16ProposalGenerator::GenerateFromCommands(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a set-tile command
|
// Check for different command types
|
||||||
if (absl::StrContains(command, "overworld set-tile")) {
|
if (absl::StrContains(command, "overworld set-tile")) {
|
||||||
auto change_or = ParseSetTileCommand(command, rom);
|
auto change_or = ParseSetTileCommand(command, rom);
|
||||||
if (change_or.ok()) {
|
if (change_or.ok()) {
|
||||||
@@ -330,8 +513,25 @@ absl::StatusOr<Tile16Proposal> Tile16ProposalGenerator::GenerateFromCommands(
|
|||||||
} else {
|
} else {
|
||||||
return change_or.status();
|
return change_or.status();
|
||||||
}
|
}
|
||||||
|
} else if (absl::StrContains(command, "overworld set-area")) {
|
||||||
|
auto changes_or = ParseSetAreaCommand(command, rom);
|
||||||
|
if (changes_or.ok()) {
|
||||||
|
proposal.changes.insert(proposal.changes.end(),
|
||||||
|
changes_or.value().begin(),
|
||||||
|
changes_or.value().end());
|
||||||
|
} else {
|
||||||
|
return changes_or.status();
|
||||||
|
}
|
||||||
|
} else if (absl::StrContains(command, "overworld replace-tile")) {
|
||||||
|
auto changes_or = ParseReplaceTileCommand(command, rom);
|
||||||
|
if (changes_or.ok()) {
|
||||||
|
proposal.changes.insert(proposal.changes.end(),
|
||||||
|
changes_or.value().begin(),
|
||||||
|
changes_or.value().end());
|
||||||
|
} else {
|
||||||
|
return changes_or.status();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// TODO: Add support for other command types (set-area, replace-tile, etc.)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (proposal.changes.empty()) {
|
if (proposal.changes.empty()) {
|
||||||
@@ -381,18 +581,121 @@ absl::Status Tile16ProposalGenerator::ApplyProposal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
absl::StatusOr<gfx::Bitmap> Tile16ProposalGenerator::GenerateDiff(
|
absl::StatusOr<gfx::Bitmap> Tile16ProposalGenerator::GenerateDiff(
|
||||||
const Tile16Proposal& /* proposal */,
|
const Tile16Proposal& proposal,
|
||||||
Rom* /* before_rom */,
|
Rom* before_rom,
|
||||||
Rom* /* after_rom */) {
|
Rom* after_rom) {
|
||||||
|
|
||||||
// TODO: Implement visual diff generation
|
if (!before_rom || !before_rom->is_loaded()) {
|
||||||
// This would:
|
return absl::FailedPreconditionError("Before ROM not loaded");
|
||||||
// 1. Load overworld from both ROMs
|
}
|
||||||
// 2. Render the affected regions
|
|
||||||
// 3. Create side-by-side or overlay comparison
|
|
||||||
// 4. Highlight changed tiles
|
|
||||||
|
|
||||||
return absl::UnimplementedError("Visual diff generation not yet implemented");
|
if (!after_rom || !after_rom->is_loaded()) {
|
||||||
|
return absl::FailedPreconditionError("After ROM not loaded");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (proposal.changes.empty()) {
|
||||||
|
return absl::InvalidArgumentError("No changes to visualize");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the bounding box of all changes
|
||||||
|
int min_x = INT_MAX, min_y = INT_MAX;
|
||||||
|
int max_x = INT_MIN, max_y = INT_MIN;
|
||||||
|
int map_id = proposal.changes[0].map_id;
|
||||||
|
|
||||||
|
for (const auto& change : proposal.changes) {
|
||||||
|
if (change.x < min_x) min_x = change.x;
|
||||||
|
if (change.y < min_y) min_y = change.y;
|
||||||
|
if (change.x > max_x) max_x = change.x;
|
||||||
|
if (change.y > max_y) max_y = change.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add some padding around the changes
|
||||||
|
int padding = 2;
|
||||||
|
min_x = std::max(0, min_x - padding);
|
||||||
|
min_y = std::max(0, min_y - padding);
|
||||||
|
max_x = std::min(31, max_x + padding);
|
||||||
|
max_y = std::min(31, max_y + padding);
|
||||||
|
|
||||||
|
int width = (max_x - min_x + 1) * 16;
|
||||||
|
int height = (max_y - min_y + 1) * 16;
|
||||||
|
|
||||||
|
// Create a side-by-side diff bitmap (before on left, after on right)
|
||||||
|
int diff_width = width * 2 + 8; // 8 pixels separator
|
||||||
|
int diff_height = height;
|
||||||
|
|
||||||
|
std::vector<uint8_t> diff_data(diff_width * diff_height, 0x00);
|
||||||
|
gfx::Bitmap diff_bitmap(diff_width, diff_height, 8, diff_data);
|
||||||
|
|
||||||
|
// Load overworld data from both ROMs
|
||||||
|
zelda3::Overworld before_overworld(before_rom);
|
||||||
|
zelda3::Overworld after_overworld(after_rom);
|
||||||
|
|
||||||
|
auto before_status = before_overworld.Load(before_rom);
|
||||||
|
if (!before_status.ok()) {
|
||||||
|
return before_status;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto after_status = after_overworld.Load(after_rom);
|
||||||
|
if (!after_status.ok()) {
|
||||||
|
return after_status;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the correct world for both overworlds
|
||||||
|
int world = 0;
|
||||||
|
if (map_id < 0x40) {
|
||||||
|
world = 0; // Light World
|
||||||
|
} else if (map_id < 0x80) {
|
||||||
|
world = 1; // Dark World
|
||||||
|
} else {
|
||||||
|
world = 2; // Special World
|
||||||
|
}
|
||||||
|
|
||||||
|
before_overworld.set_current_world(world);
|
||||||
|
after_overworld.set_current_world(world);
|
||||||
|
|
||||||
|
// For now, create a simple colored diff representation
|
||||||
|
// Red = changed tiles, Green = unchanged tiles
|
||||||
|
// This is a placeholder until full tile rendering is implemented
|
||||||
|
|
||||||
|
gfx::SnesColor red_color(31, 0, 0); // Red for changed
|
||||||
|
gfx::SnesColor green_color(0, 31, 0); // Green for unchanged
|
||||||
|
gfx::SnesColor separator_color(15, 15, 15); // Gray separator
|
||||||
|
|
||||||
|
for (int y = min_y; y <= max_y; ++y) {
|
||||||
|
for (int x = min_x; x <= max_x; ++x) {
|
||||||
|
uint16_t before_tile = before_overworld.GetTile(x, y);
|
||||||
|
uint16_t after_tile = after_overworld.GetTile(x, y);
|
||||||
|
|
||||||
|
bool is_changed = (before_tile != after_tile);
|
||||||
|
gfx::SnesColor color = is_changed ? red_color : green_color;
|
||||||
|
|
||||||
|
// Draw "before" tile on left side
|
||||||
|
int pixel_x = (x - min_x) * 16;
|
||||||
|
int pixel_y = (y - min_y) * 16;
|
||||||
|
for (int py = 0; py < 16; ++py) {
|
||||||
|
for (int px = 0; px < 16; ++px) {
|
||||||
|
diff_bitmap.SetPixel(pixel_x + px, pixel_y + py, color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw "after" tile on right side
|
||||||
|
int right_offset = width + 8;
|
||||||
|
for (int py = 0; py < 16; ++py) {
|
||||||
|
for (int px = 0; px < 16; ++px) {
|
||||||
|
diff_bitmap.SetPixel(right_offset + pixel_x + px, pixel_y + py, color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw separator line
|
||||||
|
for (int y = 0; y < diff_height; ++y) {
|
||||||
|
for (int x = 0; x < 8; ++x) {
|
||||||
|
diff_bitmap.SetPixel(width + x, y, separator_color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return diff_bitmap;
|
||||||
}
|
}
|
||||||
|
|
||||||
absl::Status Tile16ProposalGenerator::SaveProposal(
|
absl::Status Tile16ProposalGenerator::SaveProposal(
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ class Tile16ProposalGenerator {
|
|||||||
public:
|
public:
|
||||||
Tile16ProposalGenerator() = default;
|
Tile16ProposalGenerator() = default;
|
||||||
|
|
||||||
|
// Allow testing of private methods
|
||||||
|
friend class Tile16ProposalGeneratorTest;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Generate a tile16 proposal from an AI-generated command list.
|
* @brief Generate a tile16 proposal from an AI-generated command list.
|
||||||
*
|
*
|
||||||
@@ -134,6 +137,25 @@ class Tile16ProposalGenerator {
|
|||||||
const std::string& command,
|
const std::string& command,
|
||||||
Rom* rom);
|
Rom* rom);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Parse a "overworld set-area" command into multiple Tile16Changes.
|
||||||
|
*
|
||||||
|
* Expected format: "overworld set-area --map 0 --x 10 --y 20 --width 5 --height 3 --tile 0x02E"
|
||||||
|
*/
|
||||||
|
absl::StatusOr<std::vector<Tile16Change>> ParseSetAreaCommand(
|
||||||
|
const std::string& command,
|
||||||
|
Rom* rom);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Parse a "overworld replace-tile" command into multiple Tile16Changes.
|
||||||
|
*
|
||||||
|
* Expected format: "overworld replace-tile --map 0 --old-tile 0x02E --new-tile 0x030"
|
||||||
|
* Can also specify optional bounds: --x-min 0 --y-min 0 --x-max 31 --y-max 31
|
||||||
|
*/
|
||||||
|
absl::StatusOr<std::vector<Tile16Change>> ParseReplaceTileCommand(
|
||||||
|
const std::string& command,
|
||||||
|
Rom* rom);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Generate a unique proposal ID.
|
* @brief Generate a unique proposal ID.
|
||||||
*/
|
*/
|
||||||
|
|||||||
334
test/integration/ai_gui_controller_test.cc
Normal file
334
test/integration/ai_gui_controller_test.cc
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
// Integration tests for AIGUIController
|
||||||
|
// Tests the gRPC GUI automation with vision feedback
|
||||||
|
|
||||||
|
#include "cli/service/ai/ai_gui_controller.h"
|
||||||
|
|
||||||
|
#include <gmock/gmock.h>
|
||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include "cli/service/ai/gemini_ai_service.h"
|
||||||
|
#include "cli/service/gui/gui_automation_client.h"
|
||||||
|
|
||||||
|
namespace yaze {
|
||||||
|
namespace cli {
|
||||||
|
namespace ai {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
using ::testing::_;
|
||||||
|
using ::testing::Return;
|
||||||
|
|
||||||
|
// Mock GuiAutomationClient for testing without actual GUI
|
||||||
|
class MockGuiAutomationClient : public GuiAutomationClient {
|
||||||
|
public:
|
||||||
|
MockGuiAutomationClient() : GuiAutomationClient("localhost:50052") {}
|
||||||
|
|
||||||
|
MOCK_METHOD(absl::Status, Connect, ());
|
||||||
|
MOCK_METHOD(absl::StatusOr<AutomationResult>, Ping, (const std::string&));
|
||||||
|
MOCK_METHOD(absl::StatusOr<AutomationResult>, Click,
|
||||||
|
(const std::string&, ClickType));
|
||||||
|
MOCK_METHOD(absl::StatusOr<AutomationResult>, Type,
|
||||||
|
(const std::string&, const std::string&, bool));
|
||||||
|
MOCK_METHOD(absl::StatusOr<AutomationResult>, Wait,
|
||||||
|
(const std::string&, int, int));
|
||||||
|
MOCK_METHOD(absl::StatusOr<AutomationResult>, Assert,
|
||||||
|
(const std::string&));
|
||||||
|
};
|
||||||
|
|
||||||
|
class AIGUIControllerTest : public ::testing::Test {
|
||||||
|
protected:
|
||||||
|
void SetUp() override {
|
||||||
|
// Create mock services
|
||||||
|
GeminiConfig config;
|
||||||
|
config.api_key = "test_key";
|
||||||
|
config.model = "gemini-2.5-flash";
|
||||||
|
gemini_service_ = std::make_unique<GeminiAIService>(config);
|
||||||
|
|
||||||
|
gui_client_ = std::make_unique<MockGuiAutomationClient>();
|
||||||
|
|
||||||
|
controller_ = std::make_unique<AIGUIController>(
|
||||||
|
gemini_service_.get(), gui_client_.get());
|
||||||
|
|
||||||
|
ControlLoopConfig loop_config;
|
||||||
|
loop_config.max_iterations = 5;
|
||||||
|
loop_config.enable_vision_verification = false; // Disable for unit tests
|
||||||
|
loop_config.enable_iterative_refinement = false;
|
||||||
|
controller_->Initialize(loop_config);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::unique_ptr<GeminiAIService> gemini_service_;
|
||||||
|
std::unique_ptr<MockGuiAutomationClient> gui_client_;
|
||||||
|
std::unique_ptr<AIGUIController> controller_;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Basic Action Execution Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
TEST_F(AIGUIControllerTest, ExecuteClickAction_Success) {
|
||||||
|
AIAction action(AIActionType::kClickButton);
|
||||||
|
action.parameters["target"] = "button:Test";
|
||||||
|
action.parameters["click_type"] = "left";
|
||||||
|
|
||||||
|
AutomationResult result;
|
||||||
|
result.success = true;
|
||||||
|
result.message = "Click successful";
|
||||||
|
|
||||||
|
EXPECT_CALL(*gui_client_, Click("button:Test", ClickType::kLeft))
|
||||||
|
.WillOnce(Return(result));
|
||||||
|
|
||||||
|
auto status = controller_->ExecuteSingleAction(action, false);
|
||||||
|
|
||||||
|
ASSERT_TRUE(status.ok()) << status.status().message();
|
||||||
|
EXPECT_TRUE(status->action_successful);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(AIGUIControllerTest, ExecuteClickAction_Failure) {
|
||||||
|
AIAction action(AIActionType::kClickButton);
|
||||||
|
action.parameters["target"] = "button:NonExistent";
|
||||||
|
|
||||||
|
AutomationResult result;
|
||||||
|
result.success = false;
|
||||||
|
result.message = "Button not found";
|
||||||
|
|
||||||
|
EXPECT_CALL(*gui_client_, Click("button:NonExistent", ClickType::kLeft))
|
||||||
|
.WillOnce(Return(result));
|
||||||
|
|
||||||
|
auto status = controller_->ExecuteSingleAction(action, false);
|
||||||
|
|
||||||
|
EXPECT_FALSE(status.ok());
|
||||||
|
EXPECT_THAT(status.status().message(),
|
||||||
|
::testing::HasSubstr("Click action failed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Type Action Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
TEST_F(AIGUIControllerTest, ExecuteTypeAction_Success) {
|
||||||
|
AIAction action(AIActionType::kSelectTile); // Using SelectTile as a type action
|
||||||
|
action.parameters["target"] = "input:TileID";
|
||||||
|
action.parameters["text"] = "0x42";
|
||||||
|
action.parameters["clear_first"] = "true";
|
||||||
|
|
||||||
|
AutomationResult result;
|
||||||
|
result.success = true;
|
||||||
|
result.message = "Text entered";
|
||||||
|
|
||||||
|
EXPECT_CALL(*gui_client_, Type("input:TileID", "0x42", true))
|
||||||
|
.WillOnce(Return(result));
|
||||||
|
|
||||||
|
auto status = controller_->ExecuteSingleAction(action, false);
|
||||||
|
|
||||||
|
ASSERT_TRUE(status.ok());
|
||||||
|
EXPECT_TRUE(status->action_successful);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Wait Action Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
TEST_F(AIGUIControllerTest, ExecuteWaitAction_Success) {
|
||||||
|
AIAction action(AIActionType::kWait);
|
||||||
|
action.parameters["condition"] = "window:OverworldEditor";
|
||||||
|
action.parameters["timeout_ms"] = "2000";
|
||||||
|
|
||||||
|
AutomationResult result;
|
||||||
|
result.success = true;
|
||||||
|
result.message = "Condition met";
|
||||||
|
|
||||||
|
EXPECT_CALL(*gui_client_, Wait("window:OverworldEditor", 2000, 100))
|
||||||
|
.WillOnce(Return(result));
|
||||||
|
|
||||||
|
auto status = controller_->ExecuteSingleAction(action, false);
|
||||||
|
|
||||||
|
ASSERT_TRUE(status.ok());
|
||||||
|
EXPECT_TRUE(status->action_successful);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(AIGUIControllerTest, ExecuteWaitAction_Timeout) {
|
||||||
|
AIAction action(AIActionType::kWait);
|
||||||
|
action.parameters["condition"] = "window:NonExistentWindow";
|
||||||
|
action.parameters["timeout_ms"] = "100";
|
||||||
|
|
||||||
|
AutomationResult result;
|
||||||
|
result.success = false;
|
||||||
|
result.message = "Timeout waiting for condition";
|
||||||
|
|
||||||
|
EXPECT_CALL(*gui_client_, Wait("window:NonExistentWindow", 100, 100))
|
||||||
|
.WillOnce(Return(result));
|
||||||
|
|
||||||
|
auto status = controller_->ExecuteSingleAction(action, false);
|
||||||
|
|
||||||
|
EXPECT_FALSE(status.ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Verify/Assert Action Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
TEST_F(AIGUIControllerTest, ExecuteVerifyAction_Success) {
|
||||||
|
AIAction action(AIActionType::kVerifyTile);
|
||||||
|
action.parameters["condition"] = "tile_placed";
|
||||||
|
|
||||||
|
AutomationResult result;
|
||||||
|
result.success = true;
|
||||||
|
result.message = "Assertion passed";
|
||||||
|
result.expected_value = "0x42";
|
||||||
|
result.actual_value = "0x42";
|
||||||
|
|
||||||
|
EXPECT_CALL(*gui_client_, Assert("tile_placed"))
|
||||||
|
.WillOnce(Return(result));
|
||||||
|
|
||||||
|
auto status = controller_->ExecuteSingleAction(action, false);
|
||||||
|
|
||||||
|
ASSERT_TRUE(status.ok());
|
||||||
|
EXPECT_TRUE(status->action_successful);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(AIGUIControllerTest, ExecuteVerifyAction_Failure) {
|
||||||
|
AIAction action(AIActionType::kVerifyTile);
|
||||||
|
action.parameters["condition"] = "tile_placed";
|
||||||
|
|
||||||
|
AutomationResult result;
|
||||||
|
result.success = false;
|
||||||
|
result.message = "Assertion failed";
|
||||||
|
result.expected_value = "0x42";
|
||||||
|
result.actual_value = "0x00";
|
||||||
|
|
||||||
|
EXPECT_CALL(*gui_client_, Assert("tile_placed"))
|
||||||
|
.WillOnce(Return(result));
|
||||||
|
|
||||||
|
auto status = controller_->ExecuteSingleAction(action, false);
|
||||||
|
|
||||||
|
EXPECT_FALSE(status.ok());
|
||||||
|
EXPECT_THAT(status.status().message(),
|
||||||
|
::testing::HasSubstr("Assert action failed"));
|
||||||
|
EXPECT_THAT(status.status().message(),
|
||||||
|
::testing::HasSubstr("expected: 0x42"));
|
||||||
|
EXPECT_THAT(status.status().message(),
|
||||||
|
::testing::HasSubstr("actual: 0x00"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Complex Tile Placement Action Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
TEST_F(AIGUIControllerTest, ExecutePlaceTileAction_CompleteFlow) {
|
||||||
|
AIAction action(AIActionType::kPlaceTile);
|
||||||
|
action.parameters["map_id"] = "5";
|
||||||
|
action.parameters["x"] = "10";
|
||||||
|
action.parameters["y"] = "20";
|
||||||
|
action.parameters["tile"] = "0x42";
|
||||||
|
|
||||||
|
AutomationResult result;
|
||||||
|
result.success = true;
|
||||||
|
|
||||||
|
// Expect sequence: open menu, wait for window, set map ID, click position
|
||||||
|
testing::InSequence seq;
|
||||||
|
|
||||||
|
EXPECT_CALL(*gui_client_, Click("menu:Overworld", ClickType::kLeft))
|
||||||
|
.WillOnce(Return(result));
|
||||||
|
|
||||||
|
EXPECT_CALL(*gui_client_, Wait("window:Overworld Editor", 2000, 100))
|
||||||
|
.WillOnce(Return(result));
|
||||||
|
|
||||||
|
EXPECT_CALL(*gui_client_, Type("input:Map ID", "5", true))
|
||||||
|
.WillOnce(Return(result));
|
||||||
|
|
||||||
|
EXPECT_CALL(*gui_client_, Click(::testing::_, ClickType::kLeft))
|
||||||
|
.WillOnce(Return(result));
|
||||||
|
|
||||||
|
auto status = controller_->ExecuteSingleAction(action, false);
|
||||||
|
|
||||||
|
ASSERT_TRUE(status.ok()) << status.status().message();
|
||||||
|
EXPECT_TRUE(status->action_successful);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Multiple Actions Execution Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
TEST_F(AIGUIControllerTest, ExecuteActions_MultipleActionsSuccess) {
|
||||||
|
std::vector<AIAction> actions;
|
||||||
|
|
||||||
|
AIAction action1(AIActionType::kClickButton);
|
||||||
|
action1.parameters["target"] = "button:Overworld";
|
||||||
|
actions.push_back(action1);
|
||||||
|
|
||||||
|
AIAction action2(AIActionType::kWait);
|
||||||
|
action2.parameters["condition"] = "window:OverworldEditor";
|
||||||
|
actions.push_back(action2);
|
||||||
|
|
||||||
|
AutomationResult success_result;
|
||||||
|
success_result.success = true;
|
||||||
|
|
||||||
|
EXPECT_CALL(*gui_client_, Click("button:Overworld", ClickType::kLeft))
|
||||||
|
.WillOnce(Return(success_result));
|
||||||
|
|
||||||
|
EXPECT_CALL(*gui_client_, Wait("window:OverworldEditor", 5000, 100))
|
||||||
|
.WillOnce(Return(success_result));
|
||||||
|
|
||||||
|
auto result = controller_->ExecuteActions(actions);
|
||||||
|
|
||||||
|
ASSERT_TRUE(result.ok()) << result.status().message();
|
||||||
|
EXPECT_TRUE(result->success);
|
||||||
|
EXPECT_EQ(result->actions_executed.size(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(AIGUIControllerTest, ExecuteActions_StopsOnFirstFailure) {
|
||||||
|
std::vector<AIAction> actions;
|
||||||
|
|
||||||
|
AIAction action1(AIActionType::kClickButton);
|
||||||
|
action1.parameters["target"] = "button:Test";
|
||||||
|
actions.push_back(action1);
|
||||||
|
|
||||||
|
AIAction action2(AIActionType::kClickButton);
|
||||||
|
action2.parameters["target"] = "button:NeverReached";
|
||||||
|
actions.push_back(action2);
|
||||||
|
|
||||||
|
AutomationResult failure_result;
|
||||||
|
failure_result.success = false;
|
||||||
|
failure_result.message = "First action failed";
|
||||||
|
|
||||||
|
EXPECT_CALL(*gui_client_, Click("button:Test", ClickType::kLeft))
|
||||||
|
.WillOnce(Return(failure_result));
|
||||||
|
|
||||||
|
// Second action should never be called
|
||||||
|
EXPECT_CALL(*gui_client_, Click("button:NeverReached", _))
|
||||||
|
.Times(0);
|
||||||
|
|
||||||
|
auto result = controller_->ExecuteActions(actions);
|
||||||
|
|
||||||
|
EXPECT_FALSE(result.ok());
|
||||||
|
EXPECT_EQ(result->actions_executed.size(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Error Handling Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
TEST_F(AIGUIControllerTest, ExecuteAction_InvalidActionType) {
|
||||||
|
AIAction action(AIActionType::kInvalidAction);
|
||||||
|
|
||||||
|
auto status = controller_->ExecuteSingleAction(action, false);
|
||||||
|
|
||||||
|
EXPECT_FALSE(status.ok());
|
||||||
|
EXPECT_THAT(status.status().message(),
|
||||||
|
::testing::HasSubstr("Action type not implemented"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(AIGUIControllerTest, ExecutePlaceTileAction_MissingParameters) {
|
||||||
|
AIAction action(AIActionType::kPlaceTile);
|
||||||
|
// Missing required parameters
|
||||||
|
|
||||||
|
auto status = controller_->ExecuteSingleAction(action, false);
|
||||||
|
|
||||||
|
EXPECT_FALSE(status.ok());
|
||||||
|
EXPECT_THAT(status.status().message(),
|
||||||
|
::testing::HasSubstr("requires map_id, x, y, and tile"));
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
} // namespace ai
|
||||||
|
} // namespace cli
|
||||||
|
} // namespace yaze
|
||||||
244
test/unit/cli/tile16_proposal_generator_test.cc
Normal file
244
test/unit/cli/tile16_proposal_generator_test.cc
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
// Test suite for Tile16ProposalGenerator
|
||||||
|
// Tests the new ParseSetAreaCommand and ParseReplaceTileCommand functionality
|
||||||
|
|
||||||
|
#include "cli/service/planning/tile16_proposal_generator.h"
|
||||||
|
|
||||||
|
#include <gmock/gmock.h>
|
||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include "app/rom.h"
|
||||||
|
#include "test/mocks/mock_rom.h"
|
||||||
|
|
||||||
|
namespace yaze {
|
||||||
|
namespace cli {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
using ::testing::_;
|
||||||
|
using ::testing::Return;
|
||||||
|
|
||||||
|
class Tile16ProposalGeneratorTest : public ::testing::Test {
|
||||||
|
protected:
|
||||||
|
void SetUp() override {
|
||||||
|
generator_ = std::make_unique<Tile16ProposalGenerator>();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::unique_ptr<Tile16ProposalGenerator> generator_;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ParseSetTileCommand Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
TEST_F(Tile16ProposalGeneratorTest, ParseSetTileCommand_ValidCommand) {
|
||||||
|
std::string command = "overworld set-tile --map 0 --x 10 --y 20 --tile 0x02E";
|
||||||
|
|
||||||
|
auto result = generator_->ParseSetTileCommand(command, nullptr);
|
||||||
|
|
||||||
|
ASSERT_TRUE(result.ok()) << result.status().message();
|
||||||
|
EXPECT_EQ(result->map_id, 0);
|
||||||
|
EXPECT_EQ(result->x, 10);
|
||||||
|
EXPECT_EQ(result->y, 20);
|
||||||
|
EXPECT_EQ(result->new_tile, 0x02E);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(Tile16ProposalGeneratorTest, ParseSetTileCommand_InvalidFormat) {
|
||||||
|
std::string command = "overworld set-tile --map 0"; // Missing required args
|
||||||
|
|
||||||
|
auto result = generator_->ParseSetTileCommand(command, nullptr);
|
||||||
|
|
||||||
|
EXPECT_FALSE(result.ok());
|
||||||
|
EXPECT_THAT(result.status().message(),
|
||||||
|
::testing::HasSubstr("Invalid command format"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(Tile16ProposalGeneratorTest, ParseSetTileCommand_WrongCommandType) {
|
||||||
|
std::string command = "overworld get-tile --map 0 --x 10 --y 20";
|
||||||
|
|
||||||
|
auto result = generator_->ParseSetTileCommand(command, nullptr);
|
||||||
|
|
||||||
|
EXPECT_FALSE(result.ok());
|
||||||
|
EXPECT_THAT(result.status().message(),
|
||||||
|
::testing::HasSubstr("Not a set-tile command"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ParseSetAreaCommand Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
TEST_F(Tile16ProposalGeneratorTest, ParseSetAreaCommand_ValidCommand) {
|
||||||
|
std::string command =
|
||||||
|
"overworld set-area --map 0 --x 10 --y 20 --width 5 --height 3 --tile 0x02E";
|
||||||
|
|
||||||
|
auto result = generator_->ParseSetAreaCommand(command, nullptr);
|
||||||
|
|
||||||
|
ASSERT_TRUE(result.ok()) << result.status().message();
|
||||||
|
EXPECT_EQ(result->size(), 15); // 5 width * 3 height = 15 tiles
|
||||||
|
|
||||||
|
// Check first tile
|
||||||
|
EXPECT_EQ((*result)[0].map_id, 0);
|
||||||
|
EXPECT_EQ((*result)[0].x, 10);
|
||||||
|
EXPECT_EQ((*result)[0].y, 20);
|
||||||
|
EXPECT_EQ((*result)[0].new_tile, 0x02E);
|
||||||
|
|
||||||
|
// Check last tile
|
||||||
|
EXPECT_EQ((*result)[14].x, 14); // 10 + 4
|
||||||
|
EXPECT_EQ((*result)[14].y, 22); // 20 + 2
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(Tile16ProposalGeneratorTest, ParseSetAreaCommand_SingleTile) {
|
||||||
|
std::string command =
|
||||||
|
"overworld set-area --map 0 --x 10 --y 20 --width 1 --height 1 --tile 0x02E";
|
||||||
|
|
||||||
|
auto result = generator_->ParseSetAreaCommand(command, nullptr);
|
||||||
|
|
||||||
|
ASSERT_TRUE(result.ok());
|
||||||
|
EXPECT_EQ(result->size(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(Tile16ProposalGeneratorTest, ParseSetAreaCommand_LargeArea) {
|
||||||
|
std::string command =
|
||||||
|
"overworld set-area --map 0 --x 0 --y 0 --width 32 --height 32 --tile 0x000";
|
||||||
|
|
||||||
|
auto result = generator_->ParseSetAreaCommand(command, nullptr);
|
||||||
|
|
||||||
|
ASSERT_TRUE(result.ok());
|
||||||
|
EXPECT_EQ(result->size(), 1024); // 32 * 32
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(Tile16ProposalGeneratorTest, ParseSetAreaCommand_InvalidFormat) {
|
||||||
|
std::string command = "overworld set-area --map 0 --x 10"; // Missing args
|
||||||
|
|
||||||
|
auto result = generator_->ParseSetAreaCommand(command, nullptr);
|
||||||
|
|
||||||
|
EXPECT_FALSE(result.ok());
|
||||||
|
EXPECT_THAT(result.status().message(),
|
||||||
|
::testing::HasSubstr("Invalid set-area command format"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ParseReplaceTileCommand Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
TEST_F(Tile16ProposalGeneratorTest, ParseReplaceTileCommand_NoROM) {
|
||||||
|
std::string command =
|
||||||
|
"overworld replace-tile --map 0 --old-tile 0x02E --new-tile 0x030";
|
||||||
|
|
||||||
|
auto result = generator_->ParseReplaceTileCommand(command, nullptr);
|
||||||
|
|
||||||
|
EXPECT_FALSE(result.ok());
|
||||||
|
EXPECT_THAT(result.status().message(),
|
||||||
|
::testing::HasSubstr("ROM must be loaded"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(Tile16ProposalGeneratorTest, ParseReplaceTileCommand_InvalidFormat) {
|
||||||
|
std::string command = "overworld replace-tile --map 0"; // Missing tiles
|
||||||
|
|
||||||
|
auto result = generator_->ParseReplaceTileCommand(command, nullptr);
|
||||||
|
|
||||||
|
EXPECT_FALSE(result.ok());
|
||||||
|
EXPECT_THAT(result.status().message(),
|
||||||
|
::testing::HasSubstr("Invalid replace-tile command format"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// GenerateFromCommands Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
TEST_F(Tile16ProposalGeneratorTest, GenerateFromCommands_MultipleCommands) {
|
||||||
|
std::vector<std::string> commands = {
|
||||||
|
"overworld set-tile --map 0 --x 10 --y 20 --tile 0x02E",
|
||||||
|
"overworld set-area --map 0 --x 5 --y 5 --width 2 --height 2 --tile 0x030"
|
||||||
|
};
|
||||||
|
|
||||||
|
auto result = generator_->GenerateFromCommands(
|
||||||
|
"Test prompt", commands, "test_ai", nullptr);
|
||||||
|
|
||||||
|
ASSERT_TRUE(result.ok()) << result.status().message();
|
||||||
|
EXPECT_EQ(result->changes.size(), 5); // 1 from set-tile + 4 from set-area
|
||||||
|
EXPECT_EQ(result->prompt, "Test prompt");
|
||||||
|
EXPECT_EQ(result->ai_service, "test_ai");
|
||||||
|
EXPECT_EQ(result->status, Tile16Proposal::Status::PENDING);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(Tile16ProposalGeneratorTest, GenerateFromCommands_EmptyCommands) {
|
||||||
|
std::vector<std::string> commands = {};
|
||||||
|
|
||||||
|
auto result = generator_->GenerateFromCommands(
|
||||||
|
"Test prompt", commands, "test_ai", nullptr);
|
||||||
|
|
||||||
|
EXPECT_FALSE(result.ok());
|
||||||
|
EXPECT_THAT(result.status().message(),
|
||||||
|
::testing::HasSubstr("No valid tile16 changes found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(Tile16ProposalGeneratorTest, GenerateFromCommands_IgnoresComments) {
|
||||||
|
std::vector<std::string> commands = {
|
||||||
|
"# This is a comment",
|
||||||
|
"overworld set-tile --map 0 --x 10 --y 20 --tile 0x02E",
|
||||||
|
"# Another comment",
|
||||||
|
"" // Empty line
|
||||||
|
};
|
||||||
|
|
||||||
|
auto result = generator_->GenerateFromCommands(
|
||||||
|
"Test prompt", commands, "test_ai", nullptr);
|
||||||
|
|
||||||
|
ASSERT_TRUE(result.ok());
|
||||||
|
EXPECT_EQ(result->changes.size(), 1); // Only the valid command
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tile16Change Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
TEST_F(Tile16ProposalGeneratorTest, Tile16Change_ToString) {
|
||||||
|
Tile16Change change;
|
||||||
|
change.map_id = 5;
|
||||||
|
change.x = 10;
|
||||||
|
change.y = 20;
|
||||||
|
change.old_tile = 0x02E;
|
||||||
|
change.new_tile = 0x030;
|
||||||
|
|
||||||
|
std::string result = change.ToString();
|
||||||
|
|
||||||
|
EXPECT_THAT(result, ::testing::HasSubstr("Map 5"));
|
||||||
|
EXPECT_THAT(result, ::testing::HasSubstr("(10,20)"));
|
||||||
|
EXPECT_THAT(result, ::testing::HasSubstr("0x2e"));
|
||||||
|
EXPECT_THAT(result, ::testing::HasSubstr("0x30"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Proposal Serialization Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
TEST_F(Tile16ProposalGeneratorTest, Proposal_ToJsonAndFromJson) {
|
||||||
|
Tile16Proposal original;
|
||||||
|
original.id = "test_id_123";
|
||||||
|
original.prompt = "Test prompt";
|
||||||
|
original.ai_service = "gemini";
|
||||||
|
original.reasoning = "Test reasoning";
|
||||||
|
original.status = Tile16Proposal::Status::PENDING;
|
||||||
|
|
||||||
|
Tile16Change change;
|
||||||
|
change.map_id = 5;
|
||||||
|
change.x = 10;
|
||||||
|
change.y = 20;
|
||||||
|
change.old_tile = 0x02E;
|
||||||
|
change.new_tile = 0x030;
|
||||||
|
original.changes.push_back(change);
|
||||||
|
|
||||||
|
std::string json = original.ToJson();
|
||||||
|
auto result = Tile16Proposal::FromJson(json);
|
||||||
|
|
||||||
|
ASSERT_TRUE(result.ok()) << result.status().message();
|
||||||
|
EXPECT_EQ(result->id, original.id);
|
||||||
|
EXPECT_EQ(result->prompt, original.prompt);
|
||||||
|
EXPECT_EQ(result->ai_service, original.ai_service);
|
||||||
|
EXPECT_EQ(result->reasoning, original.reasoning);
|
||||||
|
EXPECT_EQ(result->status, original.status);
|
||||||
|
EXPECT_EQ(result->changes.size(), 1);
|
||||||
|
EXPECT_EQ(result->changes[0].map_id, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
} // namespace cli
|
||||||
|
} // namespace yaze
|
||||||
Reference in New Issue
Block a user