Files
pgames-manager/backend/tests/test_renpy_universal.py
T
mrleo1nidandClaude Sonnet 5.5 6c10d06d90
CI / frontend (push) Failing after 20s
CI / backend (push) Failing after 38s
feat: renpy.call_in_new_context("_hide_windows") becomes a click wait
Ren'Py's own _hide_windows label hides the dialogue window until the player clicks. Games call it
directly between scenes, and one of them 531 times. Other labels stay unsupported.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 02:58:24 +03:00

208 lines
9.1 KiB
Python

"""Generic Ren'Py constructs found in decompiled games (not tied to one game)."""
from app.converter.rpy_parser import RpyParser, ScriptData
def parse(source: str) -> ScriptData:
data = ScriptData()
data.prescan(source)
RpyParser(data, "t.rpy").parse(source)
return data
def steps(data: ScriptData, name: str) -> list[dict]:
image = data.images[name]
return image["timeline"]["steps"] if "timeline" in image else image["at"][-1]["atl"]["steps"]
def test_bare_numbers_and_expressions_are_pauses():
data = parse('image a:\n "x.png"\n 0.5*speed\n "y.png"\n 2\n repeat\n')
pauses = [s for s in steps(data, "a") if s["op"] == "pause"]
assert len(pauses) == 2 and "e" in pauses[0]["t"] and pauses[1]["t"] == 2.0
assert not data.blockers
def test_glued_number_and_sign_and_leading_zero():
data = parse('transform t:\n linear 2yalign 1.0\n xzoom-1\n anchor (0.5, 05)\n')
assert not data.blockers
ops = data.atl["t"]["steps"]
assert ops[0]["props"] == {"yalign": 1.0} and ops[0]["t"] == 2.0
assert ops[1]["props"] == {"xzoom": -1} and ops[2]["props"] == {"anchor": [0.5, 5]}
def test_spline_knots_and_named_position():
data = parse('transform t:\n truecenter zoom 2.0\n linear 2.0 pos (800, 50) knot (720, 100)\n')
assert not data.blockers
move = data.atl["t"]["steps"][1]
assert move["props"]["pos"] == {"spline": [[720, 100], [800, 50]]}
def test_transform_declared_after_use_is_a_transform():
data = parse('image a:\n get_face("x")\n tint()\ntransform tint():\n alpha 0.5\n')
assert "expr" in data.images["a"] and not data.blockers
def test_unconvertible_definition_blocks_only_when_used():
from app.converter.compatibility import audit_ir
data = parse('init python:\n import pygame_x\n def used():\n return 1\n$ x = 1\n')
audit_ir(data)
assert not data.blockers # nothing refers to the import
data = parse('label start:\n $ pygame_x.go()\n return\ninit python:\n import pygame_x\n')
audit_ir(data)
assert data.blockers
def test_use_expression_marks_screens_named_in_tables():
from app.converter.compatibility import audit_ir
source = ('init python:\n rows = [("entry", {})]\n'
'screen log():\n for kind, kw in rows:\n use expression kind pass (**kw)\n'
'screen entry():\n text "x"\nlabel start:\n show screen log\n return\n')
data = parse(source)
audit_ir(data)
assert "entry" in data.screen_used and not data.blockers
def test_multiline_source_comment_does_not_drop_valid_translation():
from app.converter.locales import LocaleData, _read_tl_file
loc = LocaleData("french")
source = ('translate french start_abcd:\n'
' # hero "First line\n # second line"\n'
' hero "Bonjour"\n'
'translate french strings:\n old "Hello"\n new "Salut"\n')
_read_tl_file(source, loc, {}, {})
assert loc.unmatched == 1 and loc.strings == {"Hello": "Salut"}
def test_invalid_translation_dialogue_still_raises():
import pytest
from app.converter.locales import LocaleData, _read_tl_file
from app.converter.rpy_lexer import ParseError
with pytest.raises(ParseError):
_read_tl_file('translate french start_abcd:\n hero "Unclosed\n', LocaleData("french"), {}, {})
def test_duplicate_renpy_translation_hash_uses_original_label_for_fallback():
from app.converter.locales import LocaleData, _read_tl_file
loc = LocaleData("french")
node = {"what": "Hello", "tid": "stable"}
source = 'translate french label_with_underscores_ab12cd34_1:\n # hero "Hello"\n hero "Bonjour"\n'
_read_tl_file(source, loc, {}, {"label_with_underscores": [node]})
assert loc.lines == {"stable": "Bonjour"} and not loc.unmatched
def test_build_patch_requires_exact_block_and_preserves_following_line_numbers(monkeypatch):
import hashlib
from app.games import renpy_builds
block = "init python:\n renpy.load = broken_loader\n"
replacement = "init python:\n pass\n"
monkeypatch.setitem(renpy_builds.BUILDS, "test", {
"id": "test", "blocks": {"script.rpy": {hashlib.sha256(block.encode()).hexdigest(): replacement}}})
source = block + 'label start:\n "Hi"\n'
patched = renpy_builds.prepare_source("test", "script.rpy", source)
assert patched == replacement + 'label start:\n "Hi"\n'
assert renpy_builds.prepare_source(None, "script.rpy", source) == source
assert renpy_builds.prepare_source("test", "other.rpy", source) == source
modified = source.replace("broken_loader", "other_loader")
assert renpy_builds.prepare_source("test", "script.rpy", modified) == modified
assert "broken_loader" not in renpy_builds.prepare_source("test", "script.rpy", source.replace("\n", "\r\n"))
def test_game_directory_inside_renpy_collection_is_found(tmp_path):
from app.converter.archive import find_game_dir
root = tmp_path / "Renpy" / "Example"
game = root / "game"
game.mkdir(parents=True)
(game / "scripts.rpa").write_bytes(b"archive")
assert find_game_dir(root) == game
assert find_game_dir(game) == game
(root / "renpy/common").mkdir(parents=True)
(root / "renpy/common/engine.rpy").write_text("# engine")
assert find_game_dir(root) == game
def test_image_keyword_is_expression_with_stable_tag_for_show_and_hide():
data = parse('label start:\n show image found.image with dissolve:\n zoom 0.5\n'
' hide image found.image with dissolve\n return\n')
assert not data.blockers
show, hide, _ = data.modules["t.rpy"][0]["body"]
assert show["img"]["expr"]["k"] == "attr"
assert show["as"] == hide["tag"] == "found.image"
assert show["at"] == [{"props": {"zoom": 0.5}}]
aliased = parse('label start:\n show expression found.image as popup\n'
' hide image found.image as popup\n')
show, hide = aliased.modules["t.rpy"][0]["body"]
assert show["as"] == hide["tag"] == "popup"
def test_glued_properties_after_calls_and_negative_atl_coordinates():
data = parse('transform moving:\n linear 1.0 xpos -400 ypos-200\n'
'screen map():\n imagebutton action Jump("end")tooltip "Enter" align.3,.5\n'
'label start:\n call screen map\nlabel end:\n return\n')
assert not data.blockers
assert data.atl["moving"]["steps"][0]["props"] == {"xpos": -400, "ypos": -200}
props = data.screens["map"]["body"][0]["props"]
assert props["action"]["k"] == "call" and props["tooltip"]["v"] == "Enter"
assert [v["v"] for v in props["align"]["v"]] == [0.3, 0.5]
def test_build_constants_remain_available_to_gameplay_and_screens():
data = parse('define build.release_channel = "Early Access"\nlabel start:\n return\n')
assert data.build == {"release_channel": "Early Access"}
assert "release_channel" not in data.gui
def test_python_block_with_flush_left_continuations_keeps_code_indentation():
source = ('init python:\n registry = {\n "key": {},\n}\n'
' marker = 1\n def helper():\n return registry\n'
'label start:\n $ helper()\n return\n')
data = parse(source)
assert not data.blockers
assert [s["op"] for s in data.init_code] == ["set", "set", "def"]
def test_dedenting_does_not_use_multiline_string_content_as_code_margin():
from app.converter.rpy_helpers import _parse_py
tree = _parse_py(' message = """First\nflush-left content\n last"""\n value = 1\n')
assert tree.body[0].value.value == "First\nflush-left content\n last"
assert tree.body[1].targets[0].id == "value"
def test_invalid_python_indentation_still_blocks():
data = parse('init python:\n x = 1\n y = 2\nlabel start:\n return\n')
assert any("unexpected indent" in message for message in data.blockers)
def test_compiled_image_factory_class_does_not_enter_python_init():
from app.converter.compatibility import audit_ir
source = ('init python:\n class TransitionConditionSwitch(renpy.Displayable):\n'
' def render(self):\n return renpy.render(self)\n'
'image bg = TransitionConditionSwitch(Dissolve(1), "True", "bg.png")\n'
'label start:\n scene bg\n return\n')
data = parse(source)
audit_ir(data)
assert not data.init_code and not data.blockers
assert data.images["bg"]["switch"][0][0] == {"k": "const", "v": True}
data = parse(source + 'label other:\n $ x = TransitionConditionSwitch(None)\n return\n'
'label splashscreen:\n jump other\n')
audit_ir(data)
assert any("TransitionConditionSwitch" in message for message in data.blockers)
def test_hide_windows_context_call_is_a_click_wait():
data = parse('label start:\n $ renpy.call_in_new_context("_hide_windows")\n return\n')
body = data.modules["t.rpy"][0]["body"] if isinstance(data.modules, dict) else None
assert body and body[0]["op"] == "pause" and body[0]["d"] is None
assert not data.blockers
other = parse('label start:\n $ renpy.call_in_new_context("something_else")\n return\n')
assert other.modules["t.rpy"][0]["body"][0]["op"] == "exec"