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:
scawful
2025-10-05 13:07:03 -04:00
parent c3df55d787
commit 3b7a961884
8 changed files with 1143 additions and 30 deletions

View File

@@ -261,10 +261,23 @@ absl::Status HandlePlanCommand(const std::vector<std::string>& arg_vec) {
}
auto proposal = proposal_or.value();
// TODO: Save the proposal to disk using ProposalRegistry
// For now, just print it.
auto& registry = ProposalRegistry::Instance();
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 << proposal.ToJson() << std::endl;
std::cout << "\n✅ Plan saved to: " << plan_path.string() << std::endl;
return absl::OkStatus();
}

View File

@@ -3,6 +3,7 @@
#include <chrono>
#include <thread>
#include "absl/strings/str_cat.h"
#include "absl/strings/str_format.h"
#include "absl/time/clock.h"
#include "absl/time/time.h"
@@ -18,7 +19,7 @@ namespace cli {
namespace ai {
AIGUIController::AIGUIController(GeminiAIService* gemini_service,
gui::GuiAutomationClient* gui_client)
GuiAutomationClient* gui_client)
: gemini_service_(gemini_service),
gui_client_(gui_client),
vision_refiner_(std::make_unique<VisionActionRefiner>(gemini_service)) {
@@ -244,11 +245,174 @@ absl::Status AIGUIController::ExecuteGRPCAction(const AIAction& action) {
return test_script_result.status();
}
// TODO: Implement gRPC GUI automation when GuiAutomationClient is ready
// For now, just log the generated test script
#ifdef YAZE_WITH_GRPC
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(
absl::StrCat("Action type not implemented: ",
static_cast<int>(action.type)));
}
#else
return absl::UnimplementedError(
"gRPC GUI automation not yet fully implemented. "
"GuiAutomationClient integration pending.");
"gRPC GUI automation requires building with -DYAZE_WITH_GRPC=ON");
#endif
}

View File

@@ -17,9 +17,7 @@ namespace cli {
// Forward declares
class GeminiAIService;
namespace gui {
class GuiAutomationClient;
}
class GuiAutomationClient; // In cli namespace, not cli::gui
namespace ai {
@@ -87,7 +85,7 @@ class AIGUIController {
* @param gui_client gRPC client for GUI automation
*/
AIGUIController(GeminiAIService* gemini_service,
gui::GuiAutomationClient* gui_client);
GuiAutomationClient* gui_client);
~AIGUIController() = default;
@@ -141,7 +139,7 @@ class AIGUIController {
private:
GeminiAIService* gemini_service_; // Not owned
gui::GuiAutomationClient* gui_client_; // Not owned
GuiAutomationClient* gui_client_; // Not owned
std::unique_ptr<VisionActionRefiner> vision_refiner_;
gui::GuiActionGenerator action_generator_;
ControlLoopConfig config_;

View File

@@ -299,11 +299,12 @@ absl::StatusOr<AgentResponse> GeminiAIService::GenerateResponse(
return absl::UnimplementedError(
"Gemini AI service requires JSON support. Build with -DYAZE_WITH_JSON=ON");
#else
// TODO: Implement history-aware prompting.
if (history.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);
// Skip availability check - causes segfault with current SSL setup
@@ -319,6 +320,40 @@ absl::StatusOr<AgentResponse> GeminiAIService::GenerateResponse(
try {
if (config_.verbose) {
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
@@ -328,17 +363,17 @@ absl::StatusOr<AgentResponse> GeminiAIService::GenerateResponse(
{"text", config_.system_instruction}
}}
}},
{"contents", {{
{"parts", {{
{"text", prompt}
}}}
}}},
{"contents", contents},
{"generationConfig", {
{"temperature", config_.temperature},
{"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
// (Gemini doesn't support both at the same time)
if (!function_calling_enabled_) {

View File

@@ -302,6 +302,189 @@ absl::StatusOr<Tile16Change> Tile16ProposalGenerator::ParseSetTileCommand(
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(
const std::string& prompt,
const std::vector<std::string>& commands,
@@ -322,7 +505,7 @@ absl::StatusOr<Tile16Proposal> Tile16ProposalGenerator::GenerateFromCommands(
continue;
}
// Check if it's a set-tile command
// Check for different command types
if (absl::StrContains(command, "overworld set-tile")) {
auto change_or = ParseSetTileCommand(command, rom);
if (change_or.ok()) {
@@ -330,8 +513,25 @@ absl::StatusOr<Tile16Proposal> Tile16ProposalGenerator::GenerateFromCommands(
} else {
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()) {
@@ -381,18 +581,121 @@ absl::Status Tile16ProposalGenerator::ApplyProposal(
}
absl::StatusOr<gfx::Bitmap> Tile16ProposalGenerator::GenerateDiff(
const Tile16Proposal& /* proposal */,
Rom* /* before_rom */,
Rom* /* after_rom */) {
const Tile16Proposal& proposal,
Rom* before_rom,
Rom* after_rom) {
// TODO: Implement visual diff generation
// This would:
// 1. Load overworld from both ROMs
// 2. Render the affected regions
// 3. Create side-by-side or overlay comparison
// 4. Highlight changed tiles
if (!before_rom || !before_rom->is_loaded()) {
return absl::FailedPreconditionError("Before ROM not loaded");
}
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(

View File

@@ -64,6 +64,9 @@ class Tile16ProposalGenerator {
public:
Tile16ProposalGenerator() = default;
// Allow testing of private methods
friend class Tile16ProposalGeneratorTest;
/**
* @brief Generate a tile16 proposal from an AI-generated command list.
*
@@ -134,6 +137,25 @@ class Tile16ProposalGenerator {
const std::string& command,
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.
*/

View 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

View 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