Files
pgames-manager/backend/tests/test_converter.py
T
mrleo1nidandClaude Sonnet 5.5 4418da47eb
CI / frontend (push) Successful in 23s
CI / backend (push) Successful in 1m55s
feat: report ATL problems only for used transforms; animate moving images
- Unsupported ATL constructs in a transform now block the import only when the
  transform is used (at clause, another transform, even before its definition).
  Stock screens.rpy transforms for unused screens no longer fail games
- image blocks with one picture plus linear/ease/parallel/repeat keep their
  animation (image-level ATL); the renderer also starts it
- Python: skip @gui.variant/@renpy.variant functions (inactive variants), treat
  @renpy.pure as transparent, read renpy.store.x as x
- Accept renpy.transition, end_replay, block_rollback and music set_pause/set_pan
  as presentation-only no-ops, and implement them in the player (set_pause and
  set_pan were allowed by the converter but missing at runtime)
- Skip the 0x52-URM add-on directory (in-game console / save editor) when
  parsing scripts and media
- Tests for lazy ATL warnings, moving images and variants

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 12:20:39 +03:00

198 lines
8.2 KiB
Python

import json
import shutil
import pytest
from app.blobs import read_bytes
from app.config import get_settings
from app.converter import pyexpr
from app.converter.pipeline import convert_archive
from app.converter.rpy_parser import RpyParser, ScriptData
from tests.make_sample import build
@pytest.fixture
def data_dir(tmp_path, monkeypatch):
monkeypatch.setenv("DATA_DIR", str(tmp_path / "data"))
get_settings.cache_clear()
yield tmp_path / "data"
get_settings.cache_clear()
def parse(src: str) -> ScriptData:
d = ScriptData()
RpyParser(d, "t.rpy").parse(src)
return d
def test_expr_translation():
e = pyexpr.parse_expr("a >= 1 and 'x' in inv or not flag")
assert e["k"] == "bool" and e["op"] == "or"
with pytest.raises(pyexpr.Unsupported):
pyexpr.parse_expr("(y := 1)")
def test_say_variants():
d = parse('label start:\n "narration"\n e happy "hi" with dissolve\n'
' "Bob" "named"\n centered "c"\n')
body = d.modules["t.rpy"][0]["body"]
assert {k: v for k, v in body[0].items() if k != "_ln"} == {"op": "say", "what": "narration"}
assert body[1]["who"] == "e" and body[1]["attrs"] == ["happy"] and body[1]["with"]["t"] == "dissolve"
assert body[2]["whoStr"] == "Bob"
assert body[3]["who"] == "centered"
def test_string_whitespace_and_escapes():
d = parse('label start:\n "multi\n line \\"q\\" {b}x{/b}"\n')
assert d.modules["t.rpy"][0]["body"][0]["what"] == 'multi line "q" {b}x{/b}'
def test_if_chain_and_menu():
d = parse("label start:\n if a:\n jump x\n elif b:\n pass\n else:\n"
" return\n menu:\n \"A\" if c:\n jump y\n \"B\":\n"
" pass\n")
body = d.modules["t.rpy"][0]["body"]
assert [b["c"] is None for b in body[0]["branches"]] == [False, False, True]
assert [i["text"] for i in body[1]["items"]] == ["A", "B"]
def test_unsupported_python_is_reported_not_fatal():
d = parse("init python:\n def f():\n yield 1\n x = 1\nlabel start:\n $ y = x\n")
assert d.warnings and d.init_code[0]["op"] == "set"
def test_user_functions_and_classes_are_interpreted_not_rejected():
from app.converter.compatibility import audit_ir
d = parse(
"init python:\n"
" class Mood(object):\n"
" def __init__(self, v=0):\n"
" self.v = v\n"
" def up(self, n=1):\n"
" self.v += n\n"
" return self\n"
" def bonus(m, *rest, **kw):\n"
" return m.up(2).v\n"
"default mood = Mood()\n"
"label start:\n"
" $ x = bonus(mood)\n"
" $ mood.up()\n")
audit_ir(d)
assert not d.warnings and not d.blockers
assert [s["op"] for s in d.init_code] == ["class", "def"]
def test_unknown_renpy_calls_and_unsupported_class_features_are_still_reported():
from app.converter.compatibility import audit_ir
d = parse("label start:\n $ renpy.no_such_call()\n $ missing_function()\n")
audit_ir(d)
messages = " ".join(d.warnings)
assert "renpy.no_such_call" in messages and "missing_function" in messages
d = parse("init python:\n class A(object):\n def __eq__(self, other):\n return True\n")
assert any("__eq__" in w for w in d.warnings)
def test_full_pipeline(data_dir, tmp_path):
archive = build(tmp_path / "sample.zip")
steps = []
result = convert_archive(archive, "sample.zip", tmp_path / "work", steps.append)
files = result.manifest["files"]
assert "game.json" in files and "script/script.json" in files
assert "images/bg room.webp" in files
game = json.loads(read_bytes(files["game.json"]["hash"]))
assert game["title"] == "Тестовая новелла"
assert game["resolution"] == [1280, 720]
assert game["images"]["bg room"]["file"] == "images/bg room.webp"
assert game["images"]["eileen sad"]["props"]["alpha"] == 0.7
assert "e" in game["characters"]
assert result.cover_source is not None
script = json.loads(read_bytes(files["script/script.json"]["hash"]))
assert [lbl["name"] for lbl in script["labels"]] == ["start", "walk", "stay_home", "ending"]
if shutil.which("ffmpeg"):
assert "audio/theme.ogg" in files
# every node has an id, says have tids; tl/english imported into our tables
assert all("id" in n for n in script["labels"][0]["body"])
assert game["sourceLang"] == "ru"
en = game["locales"]["en"]
assert en["renpy"] == "english" and en["lines"] == 3
lines = json.loads(read_bytes(files[en["modules"]["script/script.json"]]["hash"]))
first_say = next(n for n in script["labels"][0]["body"] if n["op"] == "say")
assert lines[first_say["tid"]]["t"] == "Welcome to the {b}test{/b} novel."
strings = json.loads(read_bytes(files[en["strings"]]["hash"]))
assert strings["Эйлин"]["t"] == "Eileen"
def test_nested_labels_and_named_menus_become_anchors():
from app.converter.pipeline import anchors
d = parse("label start:\n if x:\n label inner:\n \"a\"\n"
" menu pick:\n \"A\":\n jump .loc\n"
"label .loc:\n return\n")
labels = d.modules["t.rpy"]
assert [lbl["name"] for lbl in labels] == ["start", "inner.loc"] # local labels follow the last global one
found = anchors(labels)
assert found["inner"] == {"label": "start", "path": [0, 0, 0], "params": None}
assert found["pick"]["path"] == [1]
assert labels[0]["body"][2]["items"][0]["body"][0]["target"] == "inner.loc"
def test_call_arguments_and_label_params():
d = parse('label start:\n call greet("Bob", loud=True) from _c1\n'
'label greet(name, loud=False):\n "Hi [name]"\n return\n')
call = d.modules["t.rpy"][0]["body"][0]
assert call["target"] == "greet" and call["args"]["a"][0]["v"] == "Bob"
assert "loud" in call["args"]["kw"]
params = d.modules["t.rpy"][1]["params"]
assert params[0] == ["name", None] and params[1][1]["v"] is False
def test_atl_frames_layeredimage_and_channels():
d = parse('image blink:\n "a.png" with dissolve\n pause 0.5\n "b.png"\n 0.25\n'
' repeat\n'
'image vid:\n Movie(play="v.webm")\n'
'layeredimage eileen:\n always:\n "eileen_base"\n'
' group face:\n attribute happy default\n attribute sad "sad.png"\n'
'init python:\n renpy.music.register_channel("amb", mixer="sfx", loop=True)\n')
blink = d.images["blink"]
assert [f.get("d") for f in blink["frames"]] == [0.5, 0.25] and blink["repeat"]
assert d.images["vid"] == {"movie": "v.webm"}
layers = d.images["eileen"]["layered"]
assert layers[0] == {"always": {"ref": "eileen_base"}}
assert layers[1] == {"attr": "happy", "group": "face", "default": True,
"img": {"ref": "eileen_face_happy"}}
assert layers[2]["img"] == {"file": "sad.png"}
assert d.channels["amb"] == {"mixer": "sfx", "loop": True}
def test_moving_image_keeps_its_animation():
d = parse('image spin:\n "mark.png"\n xalign 0.5\n linear 0 rotate 0\n linear 4 rotate -360\n repeat\n')
image = d.images["spin"]
assert image["file"] == "mark.png"
spec = image["at"][0]
assert spec["props"] == {"xalign": 0.5}
assert spec["atl"]["repeat"] is True and spec["atl"]["steps"][-1]["props"] == {"rotate": -360.0}
assert not d.blockers
def test_unused_transforms_with_unsupported_atl_do_not_block():
d = parse("transform spinner(delay, cycle):\n function renpy.display.layout.example\nlabel start:\n 'Hi'\n")
assert not d.blockers
def test_gui_variants_and_renpy_store_are_understood():
d = parse(
"init python:\n"
" @gui.variant\n"
" def small():\n"
" gui.text_size = 30\n"
" @renpy.pure\n"
" def twice(x):\n"
" return x * 2\n"
" renpy.store.seen.append(1)\n")
assert [s["op"] for s in d.init_code] == ["def", "eval"]
assert d.init_code[0]["decos"] == [] and d.init_code[0]["name"] == "twice"
assert d.init_code[1]["e"]["f"]["o"] == {"k": "name", "id": "seen"}
assert not d.blockers