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();
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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_) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
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