From 28396b65c97440f1e8f4d6f17ca563e03d1fea09 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Wed, 26 Nov 2025 17:48:12 -0500 Subject: [PATCH] feat: Migrate to Python 3.14 (closes #135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace deprecated Python C API calls with modern PyConfig-based initialization: - PySys_SetArgvEx() -> PyConfig.argv (deprecated since 3.11) - Py_InspectFlag -> PyConfig.inspect (deprecated since 3.12) Fix critical memory safety bugs discovered during migration: - PyColor::from_arg() and PyVector::from_arg() now return new references instead of borrowed references, preventing use-after-free when callers call Py_DECREF on the result - GameEngine::testTimers() now holds a local shared_ptr copy during callback execution, preventing use-after-free when timer callbacks call delTimer() on themselves Fix double script execution bug with --exec flag: - Scripts were running twice because GameEngine constructor executed them, then main.cpp deleted and recreated the engine - Now reuses existing engine and just sets auto_exit_after_exec flag Update test syntax to use keyword arguments for Frame/Caption constructors. Test results: 127/130 passing (97.7%) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CMakeLists.txt | 4 +- src/CommandLineParser.cpp | 2 +- src/GameEngine.cpp | 11 +- src/GameEngine.h | 1 + src/McRFPy_API.cpp | 100 +++++++++++----- src/McRFPy_API.h | 4 +- src/PyColor.cpp | 1 + src/PyVector.cpp | 40 ++++--- src/main.cpp | 65 ++++------- tests/unit/test_frame_clipping.py | 129 ++++++++++----------- tests/unit/test_frame_clipping_advanced.py | 20 ++-- tests/unit/test_timer_object.py | 18 +-- tests/unit/test_viewport_scaling.py | 28 ++--- tests/unit/test_viewport_visual.py | 20 ++-- 14 files changed, 240 insertions(+), 203 deletions(-) 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 a012f26..ae1422b 100644 --- a/src/GameEngine.cpp +++ b/src/GameEngine.cpp @@ -336,9 +336,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 c4a99ff..1015721 100644 --- a/src/GameEngine.h +++ b/src/GameEngine.h @@ -154,6 +154,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 4908e8c..f0d8f7e 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,22 +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; + // With --exec, scripts were already executed by the first GameEngine constructor. + // Just configure auto-exit and run the existing engine to preserve timers/state. + if (config.headless) { + engine->setAutoExitAfterExec(true); } - delete engine; - engine = new GameEngine(mutable_config); - McRFPy_API::game = engine; 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 48cad99..5917aca 100644 --- a/tests/unit/test_frame_clipping.py +++ b/tests/unit/test_frame_clipping.py @@ -2,133 +2,132 @@ """Test UIFrame clipping functionality""" import mcrfpy -from mcrfpy import Color, Frame, Caption, Vector +from mcrfpy import Color, Frame, Caption 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(50, 50, 200, 150, + parent1 = Frame(pos=(50, 50), size=(200, 150), fill_color=Color(100, 100, 200), outline_color=Color(255, 255, 255), outline=2) parent1.name = "parent1" scene.append(parent1) - + # Create parent frame with clipping enabled - parent2 = Frame(300, 50, 200, 150, + parent2 = Frame(pos=(300, 50), size=(200, 150), fill_color=Color(200, 100, 100), outline_color=Color(255, 255, 255), outline=2) parent2.name = "parent2" parent2.clip_children = True scene.append(parent2) - + # Add captions to both frames - caption1 = Caption(10, 10, "This text should overflow the frame bounds") + 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(10, 10, "This text should be clipped to frame bounds") + + 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(150, 100, 100, 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(150, 100, 100, 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(50, 250, - f"Left frame: clip_children={parent1.clip_children}\n" - f"Right frame: clip_children={parent2.clip_children}") + 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(50, 300, - "Left: Children should overflow (no clipping)\n" - "Right: Children should be clipped to frame bounds\n" - "Press 'c' to toggle clipping on left frame") + 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 Window, automation + from mcrfpy import automation automation.screenshot("frame_clipping_test.png") - + print(f"Parent1 clip_children: {parent1.clip_children}") print(f"Parent2 clip_children: {parent2.clip_children}") - + # Test toggling clip_children parent1.clip_children = True print(f"After toggle - Parent1 clip_children: {parent1.clip_children}") - + # 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") except TypeError as e: print(f"PASS: clip_children correctly rejected non-boolean: {e}") - - # Test with animations - def animate_frames(runtime): - mcrfpy.delTimer("animate") - # Animate child frames to show clipping in action - # Note: For now, just move the frames manually to demonstrate clipping - parent1.children[1].x = 50 # Move child frame - parent2.children[1].x = 50 # Move child frame - - # Take another screenshot after starting animation - mcrfpy.setTimer("screenshot2", take_second_screenshot, 500) - - def take_second_screenshot(runtime): - mcrfpy.delTimer("screenshot2") - 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) - + # 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") - -# Set up keyboard handler to toggle clipping -def handle_keypress(key, modifiers): - if key == "c": - scene = mcrfpy.sceneUI("test") - parent1 = scene[0] # First frame - parent1.clip_children = not parent1.clip_children - print(f"Toggled parent1 clip_children to: {parent1.clip_children}") - mcrfpy.keypressScene(handle_keypress) - -# Schedule the test mcrfpy.setTimer("test_clipping", test_clipping, 100) - -print("Test scheduled, running...") \ No newline at end of file +print("Test scheduled, running...") diff --git a/tests/unit/test_frame_clipping_advanced.py b/tests/unit/test_frame_clipping_advanced.py index 3c3d324..b7e9a33 100644 --- a/tests/unit/test_frame_clipping_advanced.py +++ b/tests/unit/test_frame_clipping_advanced.py @@ -15,16 +15,16 @@ def test_nested_clipping(runtime): scene = mcrfpy.sceneUI("test") # Create outer frame with clipping enabled - outer = Frame(50, 50, 400, 300, + outer = Frame(pos=(50, 50), size=(400, 300), fill_color=Color(50, 50, 150), outline_color=Color(255, 255, 255), outline=3) outer.name = "outer" outer.clip_children = True scene.append(outer) - + # Create inner frame that extends beyond outer bounds - inner = Frame(200, 150, 300, 200, + inner = Frame(pos=(200, 150), size=(300, 200), fill_color=Color(150, 50, 50), outline_color=Color(255, 255, 0), outline=2) @@ -34,13 +34,13 @@ def test_nested_clipping(runtime): # Add content to inner frame that extends beyond its bounds for i in range(5): - caption = Caption(10, 30 * i, f"Line {i+1}: This text should be double-clipped") + 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(250, 100, 200, 150, + deeply_nested = Frame(pos=(250, 100), size=(200, 150), fill_color=Color(50, 150, 50), outline_color=Color(255, 0, 255), outline=2) @@ -48,11 +48,11 @@ def test_nested_clipping(runtime): inner.children.append(deeply_nested) # Add status text - status = Caption(50, 380, - "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") + 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", + 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 3713b2c..dfc3b88 100644 --- a/tests/unit/test_timer_object.py +++ b/tests/unit/test_timer_object.py @@ -10,17 +10,17 @@ call_count = 0 pause_test_count = 0 cancel_test_count = 0 -def timer_callback(elapsed_ms): +def timer_callback(timer, runtime): global call_count call_count += 1 - print(f"Timer fired! Count: {call_count}, Elapsed: {elapsed_ms}ms") + print(f"Timer fired! Count: {call_count}, Runtime: {runtime}ms") -def pause_test_callback(elapsed_ms): +def pause_test_callback(timer, runtime): global pause_test_count pause_test_count += 1 print(f"Pause test timer: {pause_test_count}") -def cancel_test_callback(elapsed_ms): +def cancel_test_callback(timer, runtime): global cancel_test_count cancel_test_count += 1 print(f"Cancel test timer: {cancel_test_count} - This should only print once!") @@ -50,14 +50,14 @@ def run_tests(runtime): timer2.pause() print(f" Timer2 paused: {timer2.paused}") print(f" Timer2 active: {timer2.active}") - + # Schedule resume after another 400ms def resume_timer2(runtime): 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) @@ -76,7 +76,7 @@ def run_tests(runtime): # Test 4: Test interval modification print("\nTest 4: Testing interval modification") - def interval_test(runtime): + def interval_test(timer, runtime): print(f" Interval test fired at {runtime}ms") timer4 = mcrfpy.Timer("interval_test", interval_test, 1000) @@ -98,12 +98,12 @@ def run_tests(runtime): print("\nTest 6: Testing restart functionality") restart_count = [0] - def restart_test(runtime): + def restart_test(timer, runtime): restart_count[0] += 1 print(f" Restart test: {restart_count[0]}") if restart_count[0] == 2: print(" Restarting timer...") - timer5.restart() + timer.restart() timer5 = mcrfpy.Timer("restart_test", restart_test, 400) diff --git a/tests/unit/test_viewport_scaling.py b/tests/unit/test_viewport_scaling.py index 1f7c433..148416c 100644 --- a/tests/unit/test_viewport_scaling.py +++ b/tests/unit/test_viewport_scaling.py @@ -24,7 +24,7 @@ def test_viewport_modes(runtime): # Create a frame that fills the game resolution to show boundaries game_res = window.game_resolution - boundary = Frame(0, 0, game_res[0], 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) @@ -41,13 +41,13 @@ def test_viewport_modes(runtime): ] for x, y, label in corners: - corner = Frame(x, y, corner_size, corner_size, + 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(x + 5, y + 5, label) + text = Caption(text=label, pos=(x + 5, y + 5)) text.font_size = 20 text.fill_color = Color(255, 255, 255) scene.append(text) @@ -55,28 +55,28 @@ def test_viewport_modes(runtime): # Add center crosshair center_x = game_res[0] // 2 center_y = game_res[1] // 2 - h_line = Frame(center_x - 50, center_y - 1, 100, 2, + h_line = Frame(pos=(center_x - 50, center_y - 1), size=(100, 2), fill_color=Color(255, 255, 0)) - v_line = Frame(center_x - 1, center_y - 50, 2, 100, + 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(10, 10, f"Mode: {window.scaling_mode}") + 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(10, 40, - "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") + 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) diff --git a/tests/unit/test_viewport_visual.py b/tests/unit/test_viewport_visual.py index 926b77e..49ca076 100644 --- a/tests/unit/test_viewport_visual.py +++ b/tests/unit/test_viewport_visual.py @@ -21,7 +21,7 @@ def test_viewport_visual(runtime): game_res = window.game_resolution # Full boundary frame - boundary = Frame(0, 0, game_res[0], game_res[1], + boundary = Frame(pos=(0, 0), size=(game_res[0], game_res[1]), fill_color=Color(40, 40, 80), outline_color=Color(255, 255, 0), outline=3) @@ -44,13 +44,13 @@ def test_viewport_visual(runtime): labels = ["TL", "TR", "BL", "BR"] for (x, y), color, label in zip(positions, colors, labels): - corner = Frame(x, y, corner_size, corner_size, + corner = Frame(pos=(x, y), size=(corner_size, corner_size), fill_color=color, outline_color=Color(255, 255, 255), outline=2) scene.append(corner) - text = Caption(x + 10, y + 10, label) + text = Caption(text=label, pos=(x + 10, y + 10)) text.font_size = 32 text.fill_color = Color(0, 0, 0) scene.append(text) @@ -58,23 +58,23 @@ def test_viewport_visual(runtime): # Center crosshair center_x = game_res[0] // 2 center_y = game_res[1] // 2 - h_line = Frame(0, center_y - 1, game_res[0], 2, + h_line = Frame(pos=(0, center_y - 1), size=(game_res[0], 2), fill_color=Color(255, 255, 255, 128)) - v_line = Frame(center_x - 1, 0, 2, game_res[1], + v_line = Frame(pos=(center_x - 1, 0), size=(2, game_res[1]), fill_color=Color(255, 255, 255, 128)) scene.append(h_line) scene.append(v_line) # Mode text - mode_text = Caption(center_x - 100, center_y - 50, - f"Mode: {window.scaling_mode}") + mode_text = Caption(text=f"Mode: {window.scaling_mode}", + pos=(center_x - 100, center_y - 50)) mode_text.font_size = 36 mode_text.fill_color = Color(255, 255, 255) scene.append(mode_text) - + # Resolution text - res_text = Caption(center_x - 150, center_y + 10, - f"Game: {game_res[0]}x{game_res[1]}") + res_text = Caption(text=f"Game: {game_res[0]}x{game_res[1]}", + pos=(center_x - 150, center_y + 10)) res_text.font_size = 24 res_text.fill_color = Color(200, 200, 200) scene.append(res_text)