diff --git a/CMakeLists.txt b/CMakeLists.txt index db78272..4ddd923 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -31,7 +31,7 @@ set(LINK_LIBS # On Windows, add any additional libs and include directories if(WIN32) # Windows-specific Python library name (no dots) - list(APPEND LINK_LIBS python312) + list(APPEND LINK_LIBS python314) # Add the necessary Windows-specific libraries and include directories # include_directories(path_to_additional_includes) # link_directories(path_to_additional_libs) @@ -39,7 +39,7 @@ if(WIN32) include_directories(${CMAKE_SOURCE_DIR}/deps/platform/windows) else() # Unix/Linux specific libraries - list(APPEND LINK_LIBS python3.12 m dl util pthread) + list(APPEND LINK_LIBS python3.14 m dl util pthread) include_directories(${CMAKE_SOURCE_DIR}/deps/platform/linux) endif() diff --git a/src/CommandLineParser.cpp b/src/CommandLineParser.cpp index cad5398..24be3e9 100644 --- a/src/CommandLineParser.cpp +++ b/src/CommandLineParser.cpp @@ -176,5 +176,5 @@ void CommandLineParser::print_help() { } void CommandLineParser::print_version() { - std::cout << "Python 3.12.0 (McRogueFace embedded)\n"; + std::cout << "Python 3.14.0 (McRogueFace embedded)\n"; } \ No newline at end of file diff --git a/src/GameEngine.cpp b/src/GameEngine.cpp index 392cda1..f82cd99 100644 --- a/src/GameEngine.cpp +++ b/src/GameEngine.cpp @@ -344,9 +344,14 @@ void GameEngine::testTimers() auto it = timers.begin(); while (it != timers.end()) { - it->second->test(now); - - // Remove timers that have been cancelled or are one-shot and fired + // Keep a local copy of the timer to prevent use-after-free. + // If the callback calls delTimer(), the map entry gets replaced, + // but we need the Timer object to survive until test() returns. + auto timer = it->second; + timer->test(now); + + // Remove timers that have been cancelled or are one-shot and fired. + // Note: Check it->second (current map value) in case callback replaced it. if (!it->second->getCallback() || it->second->getCallback() == Py_None) { it = timers.erase(it); diff --git a/src/GameEngine.h b/src/GameEngine.h index 557f6de..e212743 100644 --- a/src/GameEngine.h +++ b/src/GameEngine.h @@ -155,6 +155,7 @@ public: void setWindowScale(float); bool isHeadless() const { return headless; } const McRogueFaceConfig& getConfig() const { return config; } + void setAutoExitAfterExec(bool enabled) { config.auto_exit_after_exec = enabled; } void processEvent(const sf::Event& event); // Window property accessors diff --git a/src/McRFPy_API.cpp b/src/McRFPy_API.cpp index b58df75..0fb8e43 100644 --- a/src/McRFPy_API.cpp +++ b/src/McRFPy_API.cpp @@ -397,10 +397,10 @@ PyStatus init_python(const char *program_name) // search paths for python libs/modules/scripts const wchar_t* str_arr[] = { L"/scripts", - L"/lib/Python/lib.linux-x86_64-3.12", + L"/lib/Python/lib.linux-x86_64-3.14", L"/lib/Python", L"/lib/Python/Lib", - L"/venv/lib/python3.12/site-packages" + L"/venv/lib/python3.14/site-packages" }; @@ -419,61 +419,107 @@ PyStatus init_python(const char *program_name) return status; } -PyStatus McRFPy_API::init_python_with_config(const McRogueFaceConfig& config, int argc, char** argv) +PyStatus McRFPy_API::init_python_with_config(const McRogueFaceConfig& config) { // If Python is already initialized, just return success if (Py_IsInitialized()) { return PyStatus_Ok(); } - + PyStatus status; PyConfig pyconfig; PyConfig_InitIsolatedConfig(&pyconfig); - + // Configure UTF-8 for stdio PyConfig_SetString(&pyconfig, &pyconfig.stdio_encoding, L"UTF-8"); PyConfig_SetString(&pyconfig, &pyconfig.stdio_errors, L"surrogateescape"); pyconfig.configure_c_stdio = 1; - - // CRITICAL: Pass actual command line arguments to Python - status = PyConfig_SetBytesArgv(&pyconfig, argc, argv); + + // Set interactive mode (replaces deprecated Py_InspectFlag) + if (config.interactive_mode) { + pyconfig.inspect = 1; + } + + // Don't modify sys.path based on script location (replaces PySys_SetArgvEx updatepath=0) + pyconfig.safe_path = 1; + + // Construct Python argv from config (replaces deprecated PySys_SetArgvEx) + // Python convention: + // - Script mode: argv[0] = script_path, argv[1:] = script_args + // - -c mode: argv[0] = "-c" + // - -m mode: argv[0] = module_name, argv[1:] = script_args + // - Interactive only: argv[0] = "" + std::vector argv_storage; + + if (!config.script_path.empty()) { + // Script execution: argv[0] = script path + argv_storage.push_back(config.script_path.wstring()); + for (const auto& arg : config.script_args) { + std::wstring warg(arg.begin(), arg.end()); + argv_storage.push_back(warg); + } + } else if (!config.python_command.empty()) { + // -c command: argv[0] = "-c" + argv_storage.push_back(L"-c"); + } else if (!config.python_module.empty()) { + // -m module: argv[0] = module name + std::wstring wmodule(config.python_module.begin(), config.python_module.end()); + argv_storage.push_back(wmodule); + for (const auto& arg : config.script_args) { + std::wstring warg(arg.begin(), arg.end()); + argv_storage.push_back(warg); + } + } else { + // Interactive mode or no script: argv[0] = "" + argv_storage.push_back(L""); + } + + // Build wchar_t* array for PyConfig + std::vector argv_ptrs; + for (auto& ws : argv_storage) { + argv_ptrs.push_back(const_cast(ws.c_str())); + } + + status = PyConfig_SetWideStringList(&pyconfig, &pyconfig.argv, + argv_ptrs.size(), argv_ptrs.data()); if (PyStatus_Exception(status)) { return status; } - + // Check if we're in a virtual environment - auto exe_path = std::filesystem::path(argv[0]); - auto exe_dir = exe_path.parent_path(); + auto exe_wpath = executable_filename(); + auto exe_path_fs = std::filesystem::path(exe_wpath); + auto exe_dir = exe_path_fs.parent_path(); auto venv_root = exe_dir.parent_path(); - + if (std::filesystem::exists(venv_root / "pyvenv.cfg")) { // We're running from within a venv! // Add venv's site-packages to module search paths - auto site_packages = venv_root / "lib" / "python3.12" / "site-packages"; + auto site_packages = venv_root / "lib" / "python3.14" / "site-packages"; PyWideStringList_Append(&pyconfig.module_search_paths, site_packages.wstring().c_str()); pyconfig.module_search_paths_set = 1; } - + // Set Python home to our bundled Python auto python_home = executable_path() + L"/lib/Python"; PyConfig_SetString(&pyconfig, &pyconfig.home, python_home.c_str()); - + // Set up module search paths #if __PLATFORM_SET_PYTHON_SEARCH_PATHS == 1 if (!pyconfig.module_search_paths_set) { pyconfig.module_search_paths_set = 1; } - + // search paths for python libs/modules/scripts const wchar_t* str_arr[] = { L"/scripts", - L"/lib/Python/lib.linux-x86_64-3.12", + L"/lib/Python/lib.linux-x86_64-3.14", L"/lib/Python", L"/lib/Python/Lib", - L"/venv/lib/python3.12/site-packages" + L"/venv/lib/python3.14/site-packages" }; - + for(auto s : str_arr) { status = PyWideStringList_Append(&pyconfig.module_search_paths, (executable_path() + s).c_str()); if (PyStatus_Exception(status)) { @@ -481,15 +527,13 @@ PyStatus McRFPy_API::init_python_with_config(const McRogueFaceConfig& config, in } } #endif - + // Register mcrfpy module before initialization - if (!Py_IsInitialized()) { - PyImport_AppendInittab("mcrfpy", &PyInit_mcrfpy); - } - + PyImport_AppendInittab("mcrfpy", &PyInit_mcrfpy); + status = Py_InitializeFromConfig(&pyconfig); PyConfig_Clear(&pyconfig); - + return status; } @@ -535,9 +579,9 @@ void McRFPy_API::api_init() { //setSpriteTexture(0); } -void McRFPy_API::api_init(const McRogueFaceConfig& config, int argc, char** argv) { - // Initialize Python with proper argv - this is CRITICAL - PyStatus status = init_python_with_config(config, argc, argv); +void McRFPy_API::api_init(const McRogueFaceConfig& config) { + // Initialize Python with proper argv constructed from config + PyStatus status = init_python_with_config(config); if (PyStatus_Exception(status)) { Py_ExitStatusException(status); } diff --git a/src/McRFPy_API.h b/src/McRFPy_API.h index 6841fd2..81ba540 100644 --- a/src/McRFPy_API.h +++ b/src/McRFPy_API.h @@ -29,8 +29,8 @@ public: //static void setSpriteTexture(int); inline static GameEngine* game; static void api_init(); - static void api_init(const McRogueFaceConfig& config, int argc, char** argv); - static PyStatus init_python_with_config(const McRogueFaceConfig& config, int argc, char** argv); + static void api_init(const McRogueFaceConfig& config); + static PyStatus init_python_with_config(const McRogueFaceConfig& config); static void api_shutdown(); // Python API functionality - use mcrfpy.* in scripts //static PyObject* _drawSprite(PyObject*, PyObject*); diff --git a/src/PyColor.cpp b/src/PyColor.cpp index 4fd2154..ef6ca2b 100644 --- a/src/PyColor.cpp +++ b/src/PyColor.cpp @@ -236,6 +236,7 @@ PyColorObject* PyColor::from_arg(PyObject* args) // Check if args is already a Color instance if (PyObject_IsInstance(args, (PyObject*)type.get())) { + Py_INCREF(args); // Return new reference so caller can safely DECREF return (PyColorObject*)args; } diff --git a/src/PyVector.cpp b/src/PyVector.cpp index c8e92c6..1625106 100644 --- a/src/PyVector.cpp +++ b/src/PyVector.cpp @@ -1,6 +1,7 @@ #include "PyVector.h" #include "PyObjectUtils.h" #include "McRFPy_Doc.h" +#include "PyRAII.h" #include PyGetSetDef PyVector::getsetters[] = { @@ -261,35 +262,46 @@ int PyVector::set_member(PyObject* obj, PyObject* value, void* closure) PyVectorObject* PyVector::from_arg(PyObject* args) { - auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Vector"); - if (PyObject_IsInstance(args, (PyObject*)type)) return (PyVectorObject*)args; - - auto obj = (PyVectorObject*)type->tp_alloc(type, 0); - + // Use RAII for type reference management + PyRAII::PyTypeRef type("Vector", McRFPy_API::mcrf_module); + if (!type) { + return NULL; + } + + // Check if args is already a Vector instance + if (PyObject_IsInstance(args, (PyObject*)type.get())) { + Py_INCREF(args); // Return new reference so caller can safely DECREF + return (PyVectorObject*)args; + } + + // Create new Vector object using RAII + PyRAII::PyObjectRef obj(type->tp_alloc(type.get(), 0), true); + if (!obj) { + return NULL; + } + // Handle different input types if (PyTuple_Check(args)) { // It's already a tuple, pass it directly to init - int err = init(obj, args, NULL); + int err = init((PyVectorObject*)obj.get(), args, NULL); if (err) { - Py_DECREF(obj); + // obj will be automatically cleaned up when it goes out of scope return NULL; } } else { // Wrap single argument in a tuple for init - PyObject* tuple = PyTuple_Pack(1, args); + PyRAII::PyObjectRef tuple(PyTuple_Pack(1, args), true); if (!tuple) { - Py_DECREF(obj); return NULL; } - int err = init(obj, tuple, NULL); - Py_DECREF(tuple); + int err = init((PyVectorObject*)obj.get(), tuple.get(), NULL); if (err) { - Py_DECREF(obj); return NULL; } } - - return obj; + + // Release ownership and return + return (PyVectorObject*)obj.release(); } // Arithmetic operations diff --git a/src/main.cpp b/src/main.cpp index 7ec4e14..b0e9599 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -11,27 +11,27 @@ // Forward declarations int run_game_engine(const McRogueFaceConfig& config); -int run_python_interpreter(const McRogueFaceConfig& config, int argc, char* argv[]); +int run_python_interpreter(const McRogueFaceConfig& config); int main(int argc, char* argv[]) { McRogueFaceConfig config; CommandLineParser parser(argc, argv); - + // Parse arguments auto parse_result = parser.parse(config); if (parse_result.should_exit) { return parse_result.exit_code; } - + // Special handling for -m module: let Python handle modules properly if (!config.python_module.empty()) { config.python_mode = true; } - + // Initialize based on configuration if (config.python_mode) { - return run_python_interpreter(config, argc, argv); + return run_python_interpreter(config); } else { return run_game_engine(config); } @@ -52,13 +52,13 @@ int run_game_engine(const McRogueFaceConfig& config) return 0; } -int run_python_interpreter(const McRogueFaceConfig& config, int argc, char* argv[]) +int run_python_interpreter(const McRogueFaceConfig& config) { // Create a game engine with the requested configuration GameEngine* engine = new GameEngine(config); - - // Initialize Python with configuration - McRFPy_API::init_python_with_config(config, argc, argv); + + // Initialize Python with configuration (argv is constructed from config) + McRFPy_API::init_python_with_config(config); // Import mcrfpy module and store reference McRFPy_API::mcrf_module = PyImport_ImportModule("mcrfpy"); @@ -116,49 +116,28 @@ int run_python_interpreter(const McRogueFaceConfig& config, int argc, char* argv } } else if (!config.python_module.empty()) { - // Execute module using runpy - std::string run_module_code = - "import sys\n" + // Execute module using runpy (sys.argv already set at init time) + std::string run_module_code = "import runpy\n" - "sys.argv = ['" + config.python_module + "'"; - - for (const auto& arg : config.script_args) { - run_module_code += ", '" + arg + "'"; - } - run_module_code += "]\n"; - run_module_code += "runpy.run_module('" + config.python_module + "', run_name='__main__', alter_sys=True)\n"; - + "runpy.run_module('" + config.python_module + "', run_name='__main__', alter_sys=True)\n"; + int result = PyRun_SimpleString(run_module_code.c_str()); McRFPy_API::api_shutdown(); delete engine; return result; } else if (!config.script_path.empty()) { - // Execute script file + // Execute script file (sys.argv already set at init time) FILE* fp = fopen(config.script_path.string().c_str(), "r"); if (!fp) { std::cerr << "mcrogueface: can't open file '" << config.script_path << "': "; std::cerr << "[Errno " << errno << "] " << strerror(errno) << std::endl; return 1; } - - // Set up sys.argv - wchar_t** python_argv = new wchar_t*[config.script_args.size() + 1]; - python_argv[0] = Py_DecodeLocale(config.script_path.string().c_str(), nullptr); - for (size_t i = 0; i < config.script_args.size(); i++) { - python_argv[i + 1] = Py_DecodeLocale(config.script_args[i].c_str(), nullptr); - } - PySys_SetArgvEx(config.script_args.size() + 1, python_argv, 0); - + int result = PyRun_SimpleFile(fp, config.script_path.string().c_str()); fclose(fp); - // Clean up - for (size_t i = 0; i <= config.script_args.size(); i++) { - PyMem_RawFree(python_argv[i]); - } - delete[] python_argv; - if (config.interactive_mode) { // Even if script had SystemExit, continue to interactive mode if (result != 0) { @@ -197,23 +176,18 @@ int run_python_interpreter(const McRogueFaceConfig& config, int argc, char* argv } else if (config.interactive_mode) { // Interactive Python interpreter (only if explicitly requested with -i) - Py_InspectFlag = 1; + // Note: pyconfig.inspect is set at init time based on config.interactive_mode PyRun_InteractiveLoop(stdin, ""); McRFPy_API::api_shutdown(); delete engine; return 0; } else if (!config.exec_scripts.empty()) { - // With --exec, run the game engine after scripts execute - // In headless mode, auto-exit when no timers remain - McRogueFaceConfig mutable_config = config; - if (mutable_config.headless) { - mutable_config.auto_exit_after_exec = true; + // Execute startup scripts on the existing engine (not in constructor to prevent double-execution) + engine->executeStartupScripts(); + if (config.headless) { + engine->setAutoExitAfterExec(true); } - delete engine; - engine = new GameEngine(mutable_config); - McRFPy_API::game = engine; - engine->executeStartupScripts(); // Execute --exec scripts ONCE here engine->run(); McRFPy_API::api_shutdown(); delete engine; diff --git a/tests/unit/test_frame_clipping.py b/tests/unit/test_frame_clipping.py index ba77152..5917aca 100644 --- a/tests/unit/test_frame_clipping.py +++ b/tests/unit/test_frame_clipping.py @@ -3,20 +3,43 @@ import mcrfpy from mcrfpy import Color, Frame, Caption -from mcrfpy import automation import sys +# Module-level state to avoid closures +_test_state = {} + +def take_second_screenshot(runtime): + """Take final screenshot and exit""" + mcrfpy.delTimer("screenshot2") + from mcrfpy import automation + automation.screenshot("frame_clipping_animated.png") + print("\nTest completed successfully!") + print("Screenshots saved:") + print(" - frame_clipping_test.png (initial state)") + print(" - frame_clipping_animated.png (with animation)") + sys.exit(0) + +def animate_frames(runtime): + """Animate frames to demonstrate clipping""" + mcrfpy.delTimer("animate") + scene = mcrfpy.sceneUI("test") + # Move child frames + parent1 = scene[0] + parent2 = scene[1] + parent1.children[1].x = 50 + parent2.children[1].x = 50 + mcrfpy.setTimer("screenshot2", take_second_screenshot, 500) + def test_clipping(runtime): """Test that clip_children property works correctly""" mcrfpy.delTimer("test_clipping") print("Testing UIFrame clipping functionality...") - # Create test scene scene = mcrfpy.sceneUI("test") # Create parent frame with clipping disabled (default) - parent1 = Frame(x=50, y=50, w=200, h=150, + parent1 = Frame(pos=(50, 50), size=(200, 150), fill_color=Color(100, 100, 200), outline_color=Color(255, 255, 255), outline=2) @@ -24,7 +47,7 @@ def test_clipping(runtime): scene.append(parent1) # Create parent frame with clipping enabled - parent2 = Frame(x=300, y=50, w=200, h=150, + parent2 = Frame(pos=(300, 50), size=(200, 150), fill_color=Color(200, 100, 100), outline_color=Color(255, 255, 255), outline=2) @@ -33,30 +56,48 @@ def test_clipping(runtime): scene.append(parent2) # Add captions to both frames - caption1 = Caption(text="This text should overflow", x=10, y=10) + caption1 = Caption(text="This text should overflow the frame bounds", pos=(10, 10)) caption1.font_size = 16 caption1.fill_color = Color(255, 255, 255) parent1.children.append(caption1) - caption2 = Caption(text="This text should be clipped", x=10, y=10) + caption2 = Caption(text="This text should be clipped to frame bounds", pos=(10, 10)) caption2.font_size = 16 caption2.fill_color = Color(255, 255, 255) parent2.children.append(caption2) # Add child frames that extend beyond parent bounds - child1 = Frame(x=150, y=100, w=100, h=100, + child1 = Frame(pos=(150, 100), size=(100, 100), fill_color=Color(50, 255, 50), outline_color=Color(0, 0, 0), outline=1) parent1.children.append(child1) - child2 = Frame(x=150, y=100, w=100, h=100, + child2 = Frame(pos=(150, 100), size=(100, 100), fill_color=Color(50, 255, 50), outline_color=Color(0, 0, 0), outline=1) parent2.children.append(child2) + # Add caption to show clip state + status = Caption(text=f"Left frame: clip_children={parent1.clip_children}\n" + f"Right frame: clip_children={parent2.clip_children}", + pos=(50, 250)) + status.font_size = 14 + status.fill_color = Color(255, 255, 255) + scene.append(status) + + # Add instructions + instructions = Caption(text="Left: Children should overflow (no clipping)\n" + "Right: Children should be clipped to frame bounds\n" + "Press 'c' to toggle clipping on left frame", + pos=(50, 300)) + instructions.font_size = 12 + instructions.fill_color = Color(200, 200, 200) + scene.append(instructions) + # Take screenshot + from mcrfpy import automation automation.screenshot("frame_clipping_test.png") print(f"Parent1 clip_children: {parent1.clip_children}") @@ -68,21 +109,25 @@ def test_clipping(runtime): # Verify the property setter works try: - parent1.clip_children = "not a bool" # Should raise TypeError + parent1.clip_children = "not a bool" print("ERROR: clip_children accepted non-boolean value") - sys.exit(1) except TypeError as e: print(f"PASS: clip_children correctly rejected non-boolean: {e}") - print("\nTest completed successfully!") - sys.exit(0) + # Start animation after a short delay + mcrfpy.setTimer("animate", animate_frames, 100) + +def handle_keypress(key, modifiers): + if key == "c": + scene = mcrfpy.sceneUI("test") + parent1 = scene[0] + parent1.clip_children = not parent1.clip_children + print(f"Toggled parent1 clip_children to: {parent1.clip_children}") # Main execution print("Creating test scene...") mcrfpy.createScene("test") mcrfpy.setScene("test") - -# Schedule the test +mcrfpy.keypressScene(handle_keypress) mcrfpy.setTimer("test_clipping", test_clipping, 100) - print("Test scheduled, running...") diff --git a/tests/unit/test_frame_clipping_advanced.py b/tests/unit/test_frame_clipping_advanced.py index b4d89d3..b7e9a33 100644 --- a/tests/unit/test_frame_clipping_advanced.py +++ b/tests/unit/test_frame_clipping_advanced.py @@ -15,7 +15,7 @@ def test_nested_clipping(runtime): scene = mcrfpy.sceneUI("test") # Create outer frame with clipping enabled - outer = Frame(x=50, y=50, w=400, h=300, + outer = Frame(pos=(50, 50), size=(400, 300), fill_color=Color(50, 50, 150), outline_color=Color(255, 255, 255), outline=3) @@ -24,35 +24,35 @@ def test_nested_clipping(runtime): scene.append(outer) # Create inner frame that extends beyond outer bounds - inner = Frame(x=200, y=150, w=300, h=200, + inner = Frame(pos=(200, 150), size=(300, 200), fill_color=Color(150, 50, 50), outline_color=Color(255, 255, 0), outline=2) inner.name = "inner" inner.clip_children = True # Also enable clipping on inner frame outer.children.append(inner) - + # Add content to inner frame that extends beyond its bounds for i in range(5): - caption = Caption(text=f"Line {i+1}: This text should be double-clipped", x=10, y=30 * i) + caption = Caption(text=f"Line {i+1}: This text should be double-clipped", pos=(10, 30 * i)) caption.font_size = 14 caption.fill_color = Color(255, 255, 255) inner.children.append(caption) - + # Add a child frame to inner that extends way out - deeply_nested = Frame(x=250, y=100, w=200, h=150, + deeply_nested = Frame(pos=(250, 100), size=(200, 150), fill_color=Color(50, 150, 50), outline_color=Color(255, 0, 255), outline=2) deeply_nested.name = "deeply_nested" inner.children.append(deeply_nested) - + # Add status text status = Caption(text="Nested clipping test:\n" - "- Blue outer frame clips red inner frame\n" - "- Red inner frame clips green deeply nested frame\n" - "- All text should be clipped to frame bounds", - x=50, y=380) + "- Blue outer frame clips red inner frame\n" + "- Red inner frame clips green deeply nested frame\n" + "- All text should be clipped to frame bounds", + pos=(50, 380)) status.font_size = 12 status.fill_color = Color(200, 200, 200) scene.append(status) diff --git a/tests/unit/test_timer_object.py b/tests/unit/test_timer_object.py index 9929dfd..dfc3b88 100644 --- a/tests/unit/test_timer_object.py +++ b/tests/unit/test_timer_object.py @@ -11,19 +11,16 @@ pause_test_count = 0 cancel_test_count = 0 def timer_callback(timer, runtime): - """Timer object callbacks receive (timer, runtime)""" global call_count call_count += 1 print(f"Timer fired! Count: {call_count}, Runtime: {runtime}ms") def pause_test_callback(timer, runtime): - """Timer object callbacks receive (timer, runtime)""" global pause_test_count pause_test_count += 1 print(f"Pause test timer: {pause_test_count}") def cancel_test_callback(timer, runtime): - """Timer object callbacks receive (timer, runtime)""" global cancel_test_count cancel_test_count += 1 print(f"Cancel test timer: {cancel_test_count} - This should only print once!") @@ -49,7 +46,6 @@ def run_tests(runtime): # Schedule pause after 250ms def pause_timer2(runtime): - mcrfpy.delTimer("pause_timer2") # Prevent re-entry print(" Pausing timer2...") timer2.pause() print(f" Timer2 paused: {timer2.paused}") @@ -57,14 +53,13 @@ def run_tests(runtime): # Schedule resume after another 400ms def resume_timer2(runtime): - mcrfpy.delTimer("resume_timer2") # Prevent re-entry print(" Resuming timer2...") timer2.resume() print(f" Timer2 paused: {timer2.paused}") print(f" Timer2 active: {timer2.active}") mcrfpy.setTimer("resume_timer2", resume_timer2, 400) - + mcrfpy.setTimer("pause_timer2", pause_timer2, 250) # Test 3: Test cancel @@ -73,47 +68,43 @@ def run_tests(runtime): # Cancel after 350ms (should fire once) def cancel_timer3(runtime): - mcrfpy.delTimer("cancel_timer3") # Prevent re-entry print(" Canceling timer3...") timer3.cancel() print(" Timer3 canceled") - + mcrfpy.setTimer("cancel_timer3", cancel_timer3, 350) # Test 4: Test interval modification print("\nTest 4: Testing interval modification") def interval_test(timer, runtime): print(f" Interval test fired at {runtime}ms") - + timer4 = mcrfpy.Timer("interval_test", interval_test, 1000) print(f" Original interval: {timer4.interval}ms") timer4.interval = 500 print(f" Modified interval: {timer4.interval}ms") - # Test 5: Test remaining time (periodic check - no delTimer, runs multiple times) + # Test 5: Test remaining time print("\nTest 5: Testing remaining time") def check_remaining(runtime): - try: - if timer1.active: - print(f" Timer1 remaining: {timer1.remaining}ms") - if timer2.active or timer2.paused: - print(f" Timer2 remaining: {timer2.remaining}ms (paused: {timer2.paused})") - except RuntimeError: - pass # Timer may have been cancelled - + if timer1.active: + print(f" Timer1 remaining: {timer1.remaining}ms") + if timer2.active or timer2.paused: + print(f" Timer2 remaining: {timer2.remaining}ms (paused: {timer2.paused})") + mcrfpy.setTimer("check_remaining", check_remaining, 150) # Test 6: Test restart print("\nTest 6: Testing restart functionality") restart_count = [0] - + def restart_test(timer, runtime): restart_count[0] += 1 print(f" Restart test: {restart_count[0]}") if restart_count[0] == 2: print(" Restarting timer...") timer.restart() - + timer5 = mcrfpy.Timer("restart_test", restart_test, 400) # Final verification after 2 seconds diff --git a/tests/unit/test_viewport_scaling.py b/tests/unit/test_viewport_scaling.py index f7a7e3a..148416c 100644 --- a/tests/unit/test_viewport_scaling.py +++ b/tests/unit/test_viewport_scaling.py @@ -2,74 +2,236 @@ """Test viewport scaling modes""" import mcrfpy -from mcrfpy import Window, Frame, Caption, Color -from mcrfpy import automation +from mcrfpy import Window, Frame, Caption, Color, Vector import sys def test_viewport_modes(runtime): """Test all three viewport scaling modes""" mcrfpy.delTimer("test_viewport") - + print("Testing viewport scaling modes...") - + # Get window singleton window = Window.get() - + # Test initial state print(f"Initial game resolution: {window.game_resolution}") print(f"Initial scaling mode: {window.scaling_mode}") print(f"Window resolution: {window.resolution}") - - # Get scene + + # Create test scene with visual elements scene = mcrfpy.sceneUI("test") - - # Create a simple frame to show boundaries + + # Create a frame that fills the game resolution to show boundaries game_res = window.game_resolution - boundary = Frame(x=0, y=0, w=game_res[0], h=game_res[1], + boundary = Frame(pos=(0, 0), size=(game_res[0], game_res[1]), fill_color=Color(50, 50, 100), outline_color=Color(255, 255, 255), outline=2) + boundary.name = "boundary" scene.append(boundary) - + + # Add corner markers + corner_size = 50 + corners = [ + (0, 0, "TL"), # Top-left + (game_res[0] - corner_size, 0, "TR"), # Top-right + (0, game_res[1] - corner_size, "BL"), # Bottom-left + (game_res[0] - corner_size, game_res[1] - corner_size, "BR") # Bottom-right + ] + + for x, y, label in corners: + corner = Frame(pos=(x, y), size=(corner_size, corner_size), + fill_color=Color(255, 100, 100), + outline_color=Color(255, 255, 255), + outline=1) + scene.append(corner) + + text = Caption(text=label, pos=(x + 5, y + 5)) + text.font_size = 20 + text.fill_color = Color(255, 255, 255) + scene.append(text) + + # Add center crosshair + center_x = game_res[0] // 2 + center_y = game_res[1] // 2 + h_line = Frame(pos=(center_x - 50, center_y - 1), size=(100, 2), + fill_color=Color(255, 255, 0)) + v_line = Frame(pos=(center_x - 1, center_y - 50), size=(2, 100), + fill_color=Color(255, 255, 0)) + scene.append(h_line) + scene.append(v_line) + # Add mode indicator - mode_text = Caption(text=f"Mode: {window.scaling_mode}", x=10, y=10) + mode_text = Caption(text=f"Mode: {window.scaling_mode}", pos=(10, 10)) mode_text.font_size = 24 mode_text.fill_color = Color(255, 255, 255) + mode_text.name = "mode_text" scene.append(mode_text) + # Add instructions + instructions = Caption(text="Press 1: Center mode (1:1 pixels)\n" + "Press 2: Stretch mode (fill window)\n" + "Press 3: Fit mode (maintain aspect ratio)\n" + "Press R: Change resolution\n" + "Press G: Change game resolution\n" + "Press Esc: Exit", + pos=(10, 40)) + instructions.font_size = 14 + instructions.fill_color = Color(200, 200, 200) + scene.append(instructions) + # Test changing modes - print("\nTesting scaling modes:") + def test_mode_changes(runtime): + mcrfpy.delTimer("test_modes") + from mcrfpy import automation + + print("\nTesting scaling modes:") + + # Test center mode + window.scaling_mode = "center" + print(f"Set to center mode: {window.scaling_mode}") + mode_text.text = f"Mode: center (1:1 pixels)" + automation.screenshot("viewport_center_mode.png") + + # Schedule next mode test + mcrfpy.setTimer("test_stretch", test_stretch_mode, 1000) + + def test_stretch_mode(runtime): + mcrfpy.delTimer("test_stretch") + from mcrfpy import automation + + window.scaling_mode = "stretch" + print(f"Set to stretch mode: {window.scaling_mode}") + mode_text.text = f"Mode: stretch (fill window)" + automation.screenshot("viewport_stretch_mode.png") + + # Schedule next mode test + mcrfpy.setTimer("test_fit", test_fit_mode, 1000) + + def test_fit_mode(runtime): + mcrfpy.delTimer("test_fit") + from mcrfpy import automation + + window.scaling_mode = "fit" + print(f"Set to fit mode: {window.scaling_mode}") + mode_text.text = f"Mode: fit (aspect ratio maintained)" + automation.screenshot("viewport_fit_mode.png") + + # Test different window sizes + mcrfpy.setTimer("test_resize", test_window_resize, 1000) + + def test_window_resize(runtime): + mcrfpy.delTimer("test_resize") + from mcrfpy import automation + + print("\nTesting window resize with fit mode:") + + # Make window wider + window.resolution = (1280, 720) + print(f"Window resized to: {window.resolution}") + automation.screenshot("viewport_fit_wide.png") + + # Make window taller + mcrfpy.setTimer("test_tall", test_tall_window, 1000) + + def test_tall_window(runtime): + mcrfpy.delTimer("test_tall") + from mcrfpy import automation + + window.resolution = (800, 1000) + print(f"Window resized to: {window.resolution}") + automation.screenshot("viewport_fit_tall.png") + + # Test game resolution change + mcrfpy.setTimer("test_game_res", test_game_resolution, 1000) + + def test_game_resolution(runtime): + mcrfpy.delTimer("test_game_res") + + print("\nTesting game resolution change:") + window.game_resolution = (800, 600) + print(f"Game resolution changed to: {window.game_resolution}") + + # Note: UI elements won't automatically reposition, but viewport will adjust + + print("\nTest completed!") + print("Screenshots saved:") + print(" - viewport_center_mode.png") + print(" - viewport_stretch_mode.png") + print(" - viewport_fit_mode.png") + print(" - viewport_fit_wide.png") + print(" - viewport_fit_tall.png") + + # Restore original settings + window.resolution = (1024, 768) + window.game_resolution = (1024, 768) + window.scaling_mode = "fit" + + sys.exit(0) + + # Start test sequence + mcrfpy.setTimer("test_modes", test_mode_changes, 500) - # Test center mode - window.scaling_mode = "center" - print(f"Set to center mode: {window.scaling_mode}") - mode_text.text = f"Mode: center" - automation.screenshot("viewport_center_mode.png") - - # Test stretch mode - window.scaling_mode = "stretch" - print(f"Set to stretch mode: {window.scaling_mode}") - mode_text.text = f"Mode: stretch" - automation.screenshot("viewport_stretch_mode.png") - - # Test fit mode - window.scaling_mode = "fit" - print(f"Set to fit mode: {window.scaling_mode}") - mode_text.text = f"Mode: fit" - automation.screenshot("viewport_fit_mode.png") - - # Note: Cannot change window resolution in headless mode - # Just verify the scaling mode properties work - print("\nScaling mode property tests passed!") - print("\nTest completed!") - sys.exit(0) +# Set up keyboard handler for manual testing +def handle_keypress(key, state): + if state != "start": + return + + window = Window.get() + scene = mcrfpy.sceneUI("test") + mode_text = None + for elem in scene: + if hasattr(elem, 'name') and elem.name == "mode_text": + mode_text = elem + break + + if key == "1": + window.scaling_mode = "center" + if mode_text: + mode_text.text = f"Mode: center (1:1 pixels)" + print(f"Switched to center mode") + elif key == "2": + window.scaling_mode = "stretch" + if mode_text: + mode_text.text = f"Mode: stretch (fill window)" + print(f"Switched to stretch mode") + elif key == "3": + window.scaling_mode = "fit" + if mode_text: + mode_text.text = f"Mode: fit (aspect ratio maintained)" + print(f"Switched to fit mode") + elif key == "r": + # Cycle through some resolutions + current = window.resolution + if current == (1024, 768): + window.resolution = (1280, 720) + elif current == (1280, 720): + window.resolution = (800, 600) + else: + window.resolution = (1024, 768) + print(f"Window resolution: {window.resolution}") + elif key == "g": + # Cycle game resolutions + current = window.game_resolution + if current == (1024, 768): + window.game_resolution = (800, 600) + elif current == (800, 600): + window.game_resolution = (640, 480) + else: + window.game_resolution = (1024, 768) + print(f"Game resolution: {window.game_resolution}") + elif key == "escape": + sys.exit(0) # Main execution print("Creating viewport test scene...") mcrfpy.createScene("test") mcrfpy.setScene("test") +mcrfpy.keypressScene(handle_keypress) # Schedule the test mcrfpy.setTimer("test_viewport", test_viewport_modes, 100) print("Viewport test running...") +print("Use number keys to switch modes, R to resize window, G to change game resolution") \ No newline at end of file