From 5cc5c08122bfba4d00e3f0a9a136af834d74495c Mon Sep 17 00:00:00 2001 From: scawful Date: Tue, 30 Sep 2025 19:32:34 -0400 Subject: [PATCH] Add Overworld accessors and enhance testing framework - Introduced new methods in `Controller`, `EditorManager`, and `OverworldEditor` to access the `Overworld` instance, improving modularity and interaction with the overworld data. - Added `GetTile` method in `Overworld` to retrieve tile data based on coordinates, enhancing functionality for tile manipulation. - Implemented a new testing framework with automated GUI tests, including `CanvasSelectionTest` and `FrameworkSmokeTest`, to validate user interactions and ensure robustness. - Updated `CMakeLists.txt` to include new test files and dependencies, streamlining the build process for testing. - Refactored logging behavior to ensure proper file handling during logging operations, improving reliability in logging outputs. --- src/app/core/controller.h | 1 + src/app/editor/editor_manager.h | 1 + src/app/editor/overworld/overworld_editor.h | 1 + src/app/zelda3/overworld/overworld.h | 9 ++ src/cli/cli_main.cc | 90 +++++++++++++ src/util/log.h | 2 +- test/CMakeLists.txt | 4 +- test/e2e/canvas_selection_test.cc | 76 +++++++++++ test/e2e/canvas_selection_test.h | 8 ++ test/e2e/framework_smoke_test.cc | 16 +++ test/e2e/framework_smoke_test.h | 8 ++ test/test_utils.cc | 19 +++ test/test_utils.h | 10 ++ test/yaze_test.cc | 138 +++++++++++++++++++- 14 files changed, 374 insertions(+), 9 deletions(-) create mode 100644 test/e2e/canvas_selection_test.cc create mode 100644 test/e2e/canvas_selection_test.h create mode 100644 test/e2e/framework_smoke_test.cc create mode 100644 test/e2e/framework_smoke_test.h create mode 100644 test/test_utils.cc diff --git a/src/app/core/controller.h b/src/app/core/controller.h index d0667c7f..d0ecb077 100644 --- a/src/app/core/controller.h +++ b/src/app/core/controller.h @@ -32,6 +32,7 @@ class Controller { auto window() -> SDL_Window * { return window_.window_.get(); } void set_active(bool active) { active_ = active; } auto active() const { return active_; } + auto overworld() -> yaze::zelda3::Overworld* { return editor_manager_.overworld(); } private: friend int ::main(int argc, char **argv); diff --git a/src/app/editor/editor_manager.h b/src/app/editor/editor_manager.h index 178b464f..25df105a 100644 --- a/src/app/editor/editor_manager.h +++ b/src/app/editor/editor_manager.h @@ -101,6 +101,7 @@ class EditorManager { absl::Status SetCurrentRom(Rom* rom); auto GetCurrentRom() -> Rom* { return current_rom_; } auto GetCurrentEditorSet() -> EditorSet* { return current_editor_set_; } + auto overworld() -> yaze::zelda3::Overworld* { return ¤t_editor_set_->overworld_editor_.overworld(); } // Get current session's feature flags (falls back to global if no session) core::FeatureFlags::Flags* GetCurrentFeatureFlags() { diff --git a/src/app/editor/overworld/overworld_editor.h b/src/app/editor/overworld/overworld_editor.h index 82d93c8a..506640fe 100644 --- a/src/app/editor/overworld/overworld_editor.h +++ b/src/app/editor/overworld/overworld_editor.h @@ -94,6 +94,7 @@ class OverworldEditor : public Editor, public gfx::GfxContext { absl::Status Find() override { return absl::UnimplementedError("Find"); } absl::Status Save() override; absl::Status Clear() override; + zelda3::Overworld& overworld() { return overworld_; } /** * @brief Apply ZSCustomOverworld ASM patch to upgrade ROM version diff --git a/src/app/zelda3/overworld/overworld.h b/src/app/zelda3/overworld/overworld.h index 5a50db24..d4207553 100644 --- a/src/app/zelda3/overworld/overworld.h +++ b/src/app/zelda3/overworld/overworld.h @@ -278,6 +278,15 @@ class Overworld { auto expanded_entrances() const { return expanded_entrances_; } void set_current_map(int i) { current_map_ = i; } void set_current_world(int world) { current_world_ = world; } + uint16_t GetTile(int x, int y) const { + if (current_world_ == 0) { + return map_tiles_.light_world[y][x]; + } else if (current_world_ == 1) { + return map_tiles_.dark_world[y][x]; + } else { + return map_tiles_.special_world[y][x]; + } + } auto map_tiles() const { return map_tiles_; } auto mutable_map_tiles() { return &map_tiles_; } auto all_items() const { return all_items_; } diff --git a/src/cli/cli_main.cc b/src/cli/cli_main.cc index 66f65159..e2081b4d 100644 --- a/src/cli/cli_main.cc +++ b/src/cli/cli_main.cc @@ -9,6 +9,12 @@ #include "absl/flags/flag.h" #include "absl/flags/parse.h" #include "absl/flags/usage.h" +#include +#include +#ifdef __APPLE__ +#include +#endif + #include "absl/strings/str_format.h" #include "absl/strings/str_join.h" #include "absl/strings/str_cat.h" @@ -29,6 +35,9 @@ ABSL_FLAG(std::string, rom, "", "Path to the ROM file"); ABSL_FLAG(std::string, output, "", "Output file path"); ABSL_FLAG(bool, dry_run, false, "Perform a dry run without making changes"); ABSL_FLAG(bool, backup, true, "Create a backup before modifying files"); +ABSL_FLAG(std::string, test, "", "Name of the test to run"); +ABSL_FLAG(bool, show_gui, false, "Show the test engine GUI"); + namespace yaze { namespace cli { @@ -118,6 +127,15 @@ class ModernCLI { return HandleHelpCommand(args); } }; + + commands_["test-gui"] = { + .name = "test-gui", + .description = "Run automated GUI tests", + .usage = "z3ed test-gui --rom= --test=", + .handler = [this](const std::vector& args) -> absl::Status { + return HandleTestGuiCommand(args); + } + }; } void ShowHelp(const std::string& command = "") { @@ -417,6 +435,78 @@ class ModernCLI { } } + absl::Status HandleTestGuiCommand(const std::vector& args) { + std::string rom_file = absl::GetFlag(FLAGS_rom); + std::string test_name = absl::GetFlag(FLAGS_test); + + if (rom_file.empty()) { + return absl::InvalidArgumentError("ROM file required (use --rom=)"); + } + + // Get the path to the current executable + char exe_path[1024]; +#ifdef __APPLE__ + uint32_t size = sizeof(exe_path); + if (_NSGetExecutablePath(exe_path, &size) != 0) { + return absl::InternalError("Could not get executable path"); + } +#else + ssize_t len = readlink("/proc/self/exe", exe_path, sizeof(exe_path) - 1); + if (len == -1) { + return absl::InternalError("Could not get executable path"); + } + exe_path[len] = '\0'; +#endif + + std::string exe_dir = std::string(exe_path); + exe_dir = exe_dir.substr(0, exe_dir.find_last_of("/")); + + std::string yaze_test_path = exe_dir + "/yaze_test"; + + std::vector command_args; + command_args.push_back(yaze_test_path); + command_args.push_back("--enable-ui-tests"); + command_args.push_back("--rom-path=" + rom_file); + if (!test_name.empty()) { + command_args.push_back(test_name); + } + if (absl::GetFlag(FLAGS_show_gui)) { + command_args.push_back("--show-gui"); + } + + std::vector argv; + for (const auto& arg : command_args) { + argv.push_back((char*)arg.c_str()); + } + argv.push_back(nullptr); + + pid_t pid = fork(); + if (pid == -1) { + return absl::InternalError("Failed to fork process"); + } + + if (pid == 0) { + // Child process + execv(yaze_test_path.c_str(), argv.data()); + // If execv returns, it must have failed + return absl::InternalError("Failed to execute yaze_test"); + } else { + // Parent process + int status; + waitpid(pid, &status, 0); + if (WIFEXITED(status)) { + int exit_code = WEXITSTATUS(status); + if (exit_code == 0) { + return absl::OkStatus(); + } else { + return absl::InternalError(absl::StrFormat("yaze_test exited with code %d", exit_code)); + } + } + } + + return absl::OkStatus(); + } + absl::Status HandleHelpCommand(const std::vector& args) { std::string command = args.empty() ? "" : args[0]; ShowHelp(command); diff --git a/src/util/log.h b/src/util/log.h index 9da1b7b9..152c2f6a 100644 --- a/src/util/log.h +++ b/src/util/log.h @@ -42,7 +42,7 @@ static void logf(const absl::FormatSpec &format, const Args &...args) { // Reopen file if path changed if (g_log_file_path != last_log_path) { fout.close(); - fout.open(g_log_file_path, std::ios::out | std::ios::app); + fout.open(g_log_file_path, std::ios::out | std::ios::trunc); last_log_path = g_log_file_path; } diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index fb52fb1e..469d3b02 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -74,6 +74,7 @@ if(YAZE_BUILD_TESTS AND NOT YAZE_BUILD_TESTS STREQUAL "OFF") test_editor.h testing.h test_utils.h + test_utils.cc # Unit Tests unit/core/asar_wrapper_test.cc @@ -101,6 +102,8 @@ if(YAZE_BUILD_TESTS AND NOT YAZE_BUILD_TESTS STREQUAL "OFF") integration/editor/editor_integration_test.h # E2E Tests (included in development builds) + e2e/canvas_selection_test.cc + e2e/framework_smoke_test.cc e2e/rom_dependent/e2e_rom_test.cc e2e/zscustomoverworld/zscustomoverworld_upgrade_test.cc @@ -212,7 +215,6 @@ endif() ${PNG_LIBRARIES} ${OPENGL_LIBRARIES} ${CMAKE_DL_LIBS} - ImGui gmock_main gmock gtest_main diff --git a/test/e2e/canvas_selection_test.cc b/test/e2e/canvas_selection_test.cc new file mode 100644 index 00000000..a4d370aa --- /dev/null +++ b/test/e2e/canvas_selection_test.cc @@ -0,0 +1,76 @@ +#define IMGUI_DEFINE_MATH_OPERATORS +#include "e2e/canvas_selection_test.h" +#include "app/core/controller.h" +#include "test_utils.h" + +void E2ETest_CanvasSelectionTest(ImGuiTestContext* ctx) +{ + yaze::test::gui::LoadRomInTest(ctx, "zelda3.sfc"); + yaze::core::Controller* controller = (yaze::core::Controller*)ctx->Test->UserData; + yaze::zelda3::Overworld* overworld = controller->overworld(); + + // 1. Open the Overworld Editor + yaze::test::gui::OpenEditorInTest(ctx, "Overworld Editor"); + + // 2. Find the canvas + ctx->WindowFocus("Overworld Editor"); + ctx->ItemClick("##Canvas"); + + // 3. Get the original tile data + // We'll check the 2x2 tile area at the paste location (600, 300) + // The tile at (600, 300) is at (75, 37) in tile coordinates. + // The overworld map is 128x128 tiles. + uint16_t orig_tile1 = overworld->GetTile(75, 37); + uint16_t orig_tile2 = overworld->GetTile(76, 37); + uint16_t orig_tile3 = overworld->GetTile(75, 38); + uint16_t orig_tile4 = overworld->GetTile(76, 38); + + // 4. Perform a rectangle selection that crosses a 512px boundary + // The canvas is 1024x1024, with the top-left at (0,0). + // We'll select a 2x2 tile area from (510, 256) to (514, 258). + // This will cross the 512px boundary. + ctx->MouseMoveToPos(ImVec2(510, 256)); + ctx->MouseDown(0); + ctx->MouseMoveToPos(ImVec2(514, 258)); + ctx->MouseUp(0); + + // 5. Copy the selection + ctx->KeyDown(ImGuiKey_LeftCtrl); + ctx->KeyPress(ImGuiKey_C); + ctx->KeyUp(ImGuiKey_LeftCtrl); + + // 6. Paste the selection + ctx->MouseMoveToPos(ImVec2(600, 300)); + ctx->KeyDown(ImGuiKey_LeftCtrl); + ctx->KeyPress(ImGuiKey_V); + ctx->KeyUp(ImGuiKey_LeftCtrl); + + // 7. Verify that the pasted tiles are correct + uint16_t new_tile1 = overworld->GetTile(75, 37); + uint16_t new_tile2 = overworld->GetTile(76, 37); + uint16_t new_tile3 = overworld->GetTile(75, 38); + uint16_t new_tile4 = overworld->GetTile(76, 38); + + // The bug is that the selection wraps around, so the pasted tiles are incorrect. + // We expect the new tiles to be different from the original tiles. + IM_CHECK_NE(orig_tile1, new_tile1); + IM_CHECK_NE(orig_tile2, new_tile2); + IM_CHECK_NE(orig_tile3, new_tile3); + IM_CHECK_NE(orig_tile4, new_tile4); + + // We also expect the pasted tiles to be the same as the selected tiles. + // The selected tiles are at (63, 32) and (64, 32), (63, 33) and (64, 33). + uint16_t selected_tile1 = overworld->GetTile(63, 32); + uint16_t selected_tile2 = overworld->GetTile(64, 32); + uint16_t selected_tile3 = overworld->GetTile(63, 33); + uint16_t selected_tile4 = overworld->GetTile(64, 33); + + IM_CHECK_EQ(new_tile1, selected_tile1); + IM_CHECK_EQ(new_tile2, selected_tile2); + IM_CHECK_EQ(new_tile3, selected_tile3); + IM_CHECK_EQ(new_tile4, selected_tile4); + + ctx->LogInfo("Original tiles: %d, %d, %d, %d", orig_tile1, orig_tile2, orig_tile3, orig_tile4); + ctx->LogInfo("Selected tiles: %d, %d, %d, %d", selected_tile1, selected_tile2, selected_tile3, selected_tile4); + ctx->LogInfo("New tiles: %d, %d, %d, %d", new_tile1, new_tile2, new_tile3, new_tile4); +} diff --git a/test/e2e/canvas_selection_test.h b/test/e2e/canvas_selection_test.h new file mode 100644 index 00000000..4b8e9380 --- /dev/null +++ b/test/e2e/canvas_selection_test.h @@ -0,0 +1,8 @@ +#ifndef YAZE_TEST_E2E_CANVAS_SELECTION_TEST_H +#define YAZE_TEST_E2E_CANVAS_SELECTION_TEST_H + +#include "imgui_test_engine/imgui_te_context.h" + +void E2ETest_CanvasSelectionTest(ImGuiTestContext* ctx); + +#endif // YAZE_TEST_E2E_CANVAS_SELECTION_TEST_H diff --git a/test/e2e/framework_smoke_test.cc b/test/e2e/framework_smoke_test.cc new file mode 100644 index 00000000..983284f8 --- /dev/null +++ b/test/e2e/framework_smoke_test.cc @@ -0,0 +1,16 @@ +#include "e2e/framework_smoke_test.h" +#include "test_utils.h" +#include "imgui.h" +#include "imgui_test_engine/imgui_te_context.h" + +// Smoke test for the E2E testing framework. +// This test is run by the `test-gui` command. +// It opens a window, clicks a button, and verifies that the button was clicked. +// The GUI for this test is rendered in `test/yaze_test.cc`. +void E2ETest_FrameworkSmokeTest(ImGuiTestContext* ctx) +{ + yaze::test::gui::LoadRomInTest(ctx, "zelda3.sfc"); + ctx->SetRef("Hello World Window"); + ctx->ItemClick("Button"); + ctx->ItemCheck("Clicked 1 times"); +} diff --git a/test/e2e/framework_smoke_test.h b/test/e2e/framework_smoke_test.h new file mode 100644 index 00000000..72a1a9a3 --- /dev/null +++ b/test/e2e/framework_smoke_test.h @@ -0,0 +1,8 @@ +#ifndef YAZE_TEST_E2E_FRAMEWORK_SMOKE_TEST_H +#define YAZE_TEST_E2E_FRAMEWORK_SMOKE_TEST_H + +#include "imgui_test_engine/imgui_te_context.h" + +void E2ETest_FrameworkSmokeTest(ImGuiTestContext* ctx); + +#endif // YAZE_TEST_E2E_FRAMEWORK_SMOKE_TEST_H diff --git a/test/test_utils.cc b/test/test_utils.cc new file mode 100644 index 00000000..77222e08 --- /dev/null +++ b/test/test_utils.cc @@ -0,0 +1,19 @@ +#include "test_utils.h" +#include "app/core/controller.h" + +namespace yaze { +namespace test { +namespace gui { + +void LoadRomInTest(ImGuiTestContext* ctx, const std::string& rom_path) { + yaze::core::Controller* controller = (yaze::core::Controller*)ctx->Test->UserData; + controller->OnEntry(rom_path); +} + +void OpenEditorInTest(ImGuiTestContext* ctx, const std::string& editor_name) { + ctx->MenuClick(absl::StrFormat("Editors/%s", editor_name).c_str()); +} + +} // namespace gui +} // namespace test +} // namespace yaze diff --git a/test/test_utils.h b/test/test_utils.h index f68ff06a..540a44ab 100644 --- a/test/test_utils.h +++ b/test/test_utils.h @@ -10,6 +10,9 @@ #include #include +#include "absl/strings/str_format.h" +#include "imgui_test_engine/imgui_te_context.h" + namespace yaze { namespace test { @@ -150,6 +153,13 @@ class RomDependentTest : public ::testing::Test { std::vector test_rom_; }; +namespace gui { + +void LoadRomInTest(ImGuiTestContext* ctx, const std::string& rom_path); +void OpenEditorInTest(ImGuiTestContext* ctx, const std::string& editor_name); + +} // namespace gui + } // namespace test } // namespace yaze diff --git a/test/yaze_test.cc b/test/yaze_test.cc index 5a5986d6..a614eff2 100644 --- a/test/yaze_test.cc +++ b/test/yaze_test.cc @@ -9,6 +9,17 @@ #include "absl/debugging/failure_signal_handler.h" #include "absl/debugging/symbolize.h" +#include "imgui/imgui.h" +#include "imgui/backends/imgui_impl_sdl2.h" +#include "imgui/backends/imgui_impl_sdlrenderer2.h" +#include "imgui_test_engine/imgui_te_context.h" +#include "imgui_test_engine/imgui_te_engine.h" +#include "imgui_test_engine/imgui_te_ui.h" +#include "app/core/window.h" +#include "app/core/controller.h" +#include "e2e/canvas_selection_test.h" +#include "e2e/framework_smoke_test.h" + // #include "test_editor.h" // Not used in main namespace yaze { @@ -36,6 +47,7 @@ struct TestConfig { bool verbose = false; bool skip_rom_tests = false; bool enable_ui_tests = false; + bool show_gui = false; }; // Parse command line arguments for better AI agent testing support @@ -98,6 +110,8 @@ TestConfig ParseArguments(int argc, char* argv[]) { config.enable_ui_tests = true; } else if (arg == "--verbose") { config.verbose = true; + } else if (arg == "--show-gui") { + config.show_gui = true; } else if (arg.find("--") != 0) { // Test pattern (not a flag) config.mode = TestMode::kSpecific; @@ -224,11 +238,121 @@ int main(int argc, char* argv[]) { // Initialize Google Test ::testing::InitGoogleTest(&argc, argv); - // Run tests - int result = RUN_ALL_TESTS(); - - // Cleanup SDL - SDL_Quit(); - - return result; + if (config.enable_ui_tests) { + // Create a window + yaze::core::Window window; + yaze::core::CreateWindow(window, SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI); + + // Create a renderer + yaze::core::Renderer::Get().CreateRenderer(window.window_.get()); + + // Setup Dear ImGui context + IMGUI_CHECKVERSION(); + ImGui::CreateContext(); + ImGuiIO& io = ImGui::GetIO(); (void)io; + io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; // Enable Keyboard Controls + io.ConfigFlags |= ImGuiConfigFlags_DockingEnable; // Enable Docking + io.ConfigFlags |= ImGuiConfigFlags_ViewportsEnable; // Enable Multi-Viewport / Platform Windows + + // Setup Dear ImGui style + ImGui::StyleColorsDark(); + + // When viewports are enabled we tweak WindowRounding/WindowBg so platform windows can look identical to regular ones. + ImGuiStyle& style = ImGui::GetStyle(); + if (io.ConfigFlags & ImGuiConfigFlags_ViewportsEnable) { + style.WindowRounding = 0.0f; + style.Colors[ImGuiCol_WindowBg].w = 1.0f; + } + + // Setup Platform/Renderer backends + ImGui_ImplSDL2_InitForSDLRenderer(window.window_.get(), yaze::core::Renderer::Get().renderer()); + ImGui_ImplSDLRenderer2_Init(yaze::core::Renderer::Get().renderer()); + + // Setup test engine + ImGuiTestEngine* engine = ImGuiTestEngine_CreateContext(); + ImGuiTestEngineIO& test_io = ImGuiTestEngine_GetIO(engine); + test_io.ConfigRunSpeed = ImGuiTestRunSpeed_Fast; + test_io.ConfigVerboseLevel = ImGuiTestVerboseLevel_Info; + test_io.ConfigVerboseLevelOnError = ImGuiTestVerboseLevel_Debug; + + yaze::core::Controller controller; + + // Register smoke test + ImGuiTest* smoke_test = IM_REGISTER_TEST(engine, "E2ETest", "FrameworkSmokeTest"); + smoke_test->TestFunc = E2ETest_FrameworkSmokeTest; + + // Register canvas selection test + ImGuiTest* canvas_test = IM_REGISTER_TEST(engine, "E2ETest", "CanvasSelectionTest"); + canvas_test->TestFunc = E2ETest_CanvasSelectionTest; + canvas_test->UserData = &controller; + + // Main loop + bool done = false; + while (!done) { + SDL_Event event; + while (SDL_PollEvent(&event)) { + ImGui_ImplSDL2_ProcessEvent(&event); + if (event.type == SDL_QUIT) { + done = true; + } + if (event.type == SDL_WINDOWEVENT && event.window.event == SDL_WINDOWEVENT_CLOSE && event.window.windowID == SDL_GetWindowID(window.window_.get())) { + done = true; + } + } + + // Start the Dear ImGui frame + ImGui_ImplSDLRenderer2_NewFrame(); + ImGui_ImplSDL2_NewFrame(); + ImGui::NewFrame(); + + // Render the UI + if (config.show_gui) { + ImGuiTestEngine_ShowTestEngineWindows(engine, &config.show_gui); + } + controller.DoRender(); + + // End the Dear ImGui frame + ImGui::Render(); + yaze::core::Renderer::Get().Clear(); + ImGui_ImplSDLRenderer2_RenderDrawData(ImGui::GetDrawData(), yaze::core::Renderer::Get().renderer()); + yaze::core::Renderer::Get().Present(); + + // Update and Render additional Platform Windows + if (io.ConfigFlags & ImGuiConfigFlags_ViewportsEnable) { + SDL_Window* backup_current_window = SDL_GL_GetCurrentWindow(); + SDL_GLContext backup_current_context = SDL_GL_GetCurrentContext(); + ImGui::UpdatePlatformWindows(); + ImGui::RenderPlatformWindowsDefault(); + SDL_GL_MakeCurrent(backup_current_window, backup_current_context); + } + + // Run test engine + ImGuiTestEngine_PostSwap(engine); + } + + // Get test result + ImGuiTestEngineResultSummary summary; + ImGuiTestEngine_GetResultSummary(engine, &summary); + int result = (summary.CountSuccess == summary.CountTested) ? 0 : 1; + + // Cleanup + controller.OnExit(); + ImGuiTestEngine_DestroyContext(engine); + ImGui_ImplSDLRenderer2_Shutdown(); + ImGui_ImplSDL2_Shutdown(); + ImGui::DestroyContext(); + + yaze::core::ShutdownWindow(window); + SDL_Quit(); + + return result; + } else { + // Run tests + int result = RUN_ALL_TESTS(); + + // Cleanup SDL + SDL_Quit(); + + return result; + } } \ No newline at end of file