Files
pgames-manager/backend/tests/test_screens.py
T
mrleo1nidandClaude Sonnet 5.5 62ece25902 feat: Ren'Py screens (screen/call screen/show screen) with actions and styles
Converter
- screens.py parses the screen language: containers (fixed, frame, vbox, hbox,
  grid, vpgrid, viewport, button, has), widgets (text, textbutton, imagebutton,
  add, bar ...), if/for/use/transclude, default, python, key, timer, on, inline ATL,
  conditional properties, and decompiled forms (unparenthesised operators,
  glued property values, comma separated actions)
- style statements, call/show/hide screen operators and Python-level
  renpy.show_screen are translated; only screens the game shows are audited,
  unused stock screens no longer block an import; undefined screens do
- ATL values may be computed at run time and transforms take parameters
  (at blink(0.2, 0.3)); blur property

Player
- screens.ts builds the DOM from the IR and rebuilds it on state changes;
  buttons with idle/hover/selected/insensitive states, tooltips, sounds, modal
  screens, timers and keys that survive re-rendering, saved with the save file
- Actions: Jump, Call, Return, SetVariable, Toggle*, SetScreenVariable, Show, Hide,
  ToggleScreen, Function, Play, Notify, If, Language, ShowMenu, OpenURL ...
- Crop/LiveCrop/Solid/Frame/Transform/DynamicDisplayable values, crop property,
  globals(), renpy.variant/change_language/seen_image, transition stubs

Tests: screen parser and runtime (linkedom DOM), screens fixture compiled from
tests/fixtures/screens.rpy. Docs: docs/SCREENS.md.

Twisted-Thoughts now passes the strict compatibility check.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 13:17:11 +03:00

162 lines
6.7 KiB
Python

import pytest
from app.converter.compatibility import CompatibilityError, audit_ir, require_supported_scripts
from app.converter.rpy_lexer import parse_tree
from app.converter.rpy_parser import RpyParser, ScriptData
HUD = '''
style hud_text:
size 28
color "#fff"
unknown_cosmetic_thing 3
transform pulse:
zoom 1.0
linear 0.5 zoom 1.1
repeat
screen hud(day=1):
zorder 5
tag hud
default open = False
frame:
xalign 0.0 yalign 0.0
background "#0008"
vbox:
spacing 4
text "Day [day]" style "hud_text"
if money > 10:
text "Rich" color "#0f0"
else:
text "Poor"
for item in inventory:
textbutton item action [SetVariable("held", item), Hide("hud")] at pulse
key "K_h" action ToggleScreenVariable("open")
timer 1.0 action NullAction() repeat True
use footer("bye")
screen footer(label):
imagebutton auto "btn_%s.png" action Return(1) hover_sound "tick.ogg"
label start:
$ money = 20
$ inventory = ["key"]
show screen hud(2)
call screen footer("x")
$ r = _return
hide screen hud
return
'''
def parse(source):
data = ScriptData()
RpyParser(data, "script.rpy").parse(source)
return data
def test_screens_styles_and_calls_become_ir():
data = parse(HUD)
assert not data.blockers, data.blockers
hud = data.screens["hud"]
assert hud["params"][0]["n"] == "day"
assert hud["props"]["zorder"] == {"k": "const", "v": 5} and hud["props"]["tag"]["id"] == "hud"
assert hud["defaults"] == [["open", {"k": "const", "v": False}]]
frame = hud["body"][0]
assert frame["w"] == "frame" and frame["props"]["background"]["v"] == "#0008"
box = frame["body"][0]
assert box["w"] == "vbox" and [n.get("w") or n["c"] for n in box["body"]] == ["text", "if", "for"]
button = box["body"][2]["body"][0]
assert button["w"] == "textbutton" and button["props"]["action"]["k"] == "list"
assert button["props"]["at"] == [{"props": {"zoom": 1.0}, "name": "pulse", "atl": "pulse"}]
assert [n.get("c") for n in hud["body"][1:]] == ["key", "timer", "use"]
assert data.styles["hud_text"]["props"]["size"]["v"] == 28
assert "unknown_cosmetic_thing" not in data.styles["hud_text"]["props"]
ops = [n["op"] for label in data.modules["script.rpy"] for n in label["body"] if n["op"].endswith("screen")]
assert ops == ["show_screen", "call_screen", "hide_screen"]
call = next(n for label in data.modules["script.rpy"] for n in label["body"] if n["op"] == "call_screen")
assert call["name"] == "footer" and call["args"][0] == {"k": "const", "v": "x"}
def test_shown_screens_are_audited_but_unshown_ones_are_not():
data = parse('screen odd():\n mousearea:\n pass\nlabel start:\n "Hi"\n')
audit_ir(data)
require_supported_scripts(data) # never shown: harmless
data = parse('screen odd():\n mousearea:\n pass\nlabel start:\n call screen odd\n')
audit_ir(data)
with pytest.raises(CompatibilityError, match="screen"):
require_supported_scripts(data)
def test_unknown_actions_and_screens_block_the_import():
data = parse('screen s():\n textbutton "x" action Preference("fullscreen")\nlabel start:\n call screen s\n')
audit_ir(data)
with pytest.raises(CompatibilityError, match="Preference"):
require_supported_scripts(data)
data = parse('label start:\n call screen nowhere\n')
audit_ir(data)
with pytest.raises(CompatibilityError, match="screen"):
require_supported_scripts(data)
def test_screens_opened_from_python_and_by_actions_are_shipped():
from app.converter.pipeline import _used_screens
data = parse(
'screen a():\n textbutton "x" action Show("b")\nscreen b():\n text "B"\nscreen c():\n text "C"\n'
'screen d():\n text "D"\nlabel start:\n $ renpy.show_screen("d")\n call screen a\n')
audit_ir(data)
require_supported_scripts(data)
assert _used_screens(data) == {"a", "b", "d"}
def test_touch_variants_are_dropped_and_later_definitions_win():
data = parse(
'screen s():\n text "one"\nscreen s():\n variant "touch"\n text "touch"\n'
'screen t():\n text "one"\nscreen t():\n text "two"\nlabel start:\n "x"\n')
assert data.screens["s"]["body"][0]["args"][0]["v"] == "one"
assert data.screens["t"]["body"][0]["args"][0]["v"] == "two"
def test_lexer_keeps_screen_blocks_nested():
lines = parse_tree("screen s():\n vbox:\n text \"a\"\n")
assert lines[0].block[0].block[0].text.strip().startswith("text")
def test_decompiled_forms_operators_glued_properties_has_transclude_and_inline_atl():
data = parse(
'transform blink(delay, cycle=1.0):\n alpha 0.0\n pause delay\n linear cycle alpha 1.0\n'
'screen card(title):\n frame:\n has vbox\n text title\n transclude\n'
'screen deck(day):\n use card("T"):\n text "Day " + str(day) size 20\n'
' text "x" pos(5, 6) xysize(10,20)\n'
' add "bg.png":\n alpha 0.0\n linear 0.5 alpha 1.0\n'
' add "dot.png" at blink(0.2, 0.3)\n'
' on "show" action NullAction()\n'
'label start:\n call screen deck(1)\n')
audit_ir(data)
require_supported_scripts(data)
deck = data.screens["deck"]["body"]
use = deck[0]
assert use["c"] == "use" and use["body"][0]["args"][0]["k"] == "bin"
assert use["body"][0]["props"]["size"]["v"] == 20
card = data.screens["card"]["body"][0]
assert card["w"] == "frame" and [n.get("w") or n["c"] for n in card["body"][0]["body"]] == ["text", "transclude"]
assert deck[1]["props"]["pos"]["k"] == "tuple" and deck[1]["props"]["xysize"]["k"] == "tuple"
assert deck[2]["props"]["alpha"] == {"k": "const", "v": 0.0}
animated = deck[2]["props"]["at"][0]
assert animated["atl"]["steps"][-1]["op"] == "move" and animated["atl"]["steps"][-1]["props"] == {"alpha": 1.0}
called = deck[3]["props"]["at"][0]
assert called["name"] == "blink" and called["atl"] == "blink" and len(called["args"]) == 2
blink = data.atl["blink"]
assert [p["n"] for p in blink["params"]] == ["delay", "cycle"]
assert blink["steps"][1] == {"op": "pause", "t": {"e": {"k": "name", "id": "delay"}}}
assert deck[4] == {"c": "on", "event": "show", "action": {"k": "call", "f": {"k": "name", "id": "NullAction"}, "a": [], "kw": {}}}
def test_computed_atl_values_make_a_transform_animated():
data = parse('transform shift(x):\n xoffset x\nlabel start:\n "Hi"\n')
block = data.atl["shift"]
assert block["params"][0]["n"] == "x"
assert block["steps"][0]["props"]["xoffset"] == {"e": {"k": "name", "id": "x"}}
assert data.transforms["shift"] == {}