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 def test_functions_that_wait_for_the_player_are_listed_transitively(): from app.converter.compatibility import audit_ir, blocking_names d = parse( "define e = Character('E')\n" "init python:\n" " def ask_name():\n" " return renpy.input('Name?')\n" " def outer():\n" " n = ask_name()\n" " e('hi ' + n)\n" " def plain(x):\n" " return x + 1\n" "label start:\n" " $ outer()\n" " $ y = plain(2)\n") audit_ir(d) assert not d.blockers assert blocking_names(d) == ["ask_name", "outer"]