feat: Migrate to Python 3.14 (closes #135)

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 <noreply@anthropic.com>
This commit is contained in:
John McCardle 2025-11-26 17:48:12 -05:00
commit 28396b65c9
14 changed files with 240 additions and 203 deletions

View file

@ -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...")
print("Test scheduled, running...")

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)