Files
pgames-manager/backend/tests/test_compatibility.py
T
mrleo1nidandClaude Sonnet 5.5 ff82d882c0 feat: add ATL animation timelines and try/raise to the Ren'Py player
Python sandbox
- try/except/else/finally, raise (incl. re-raise), assert, for/while ... else
- Multi-generator and dict comprehensions
- Builtin exception hierarchy; user exceptions subclass it
- Inside try, missing keys/indexes, division by zero, attribute access on None
  and unknown names raise Python exceptions; lookups stay lenient elsewhere
- int()/float() raise ValueError on non-numeric strings

ATL
- converter/atl.py: set/move (linear, ease, easein, easeout), pause, repeat,
  parallel, choice, block, on show/hide and named transforms become a timeline
  shipped as game.json "atl"; Move() becomes a linear slide
- player/atl.ts: timeline runner wired into the renderer; `on hide` plays
  before the picture is removed
- function, time, contains, matrixcolor and expressions still block the import

Tests: exceptions/ATL fixtures and runtime tests, converter ATL tests; README
and ARCHITECTURE updated.

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

359 lines
16 KiB
Python

import asyncio
import json
import zipfile
from types import SimpleNamespace
import pytest
from app.config import get_settings
from app.converter.compatibility import (
CompatibilityError,
audit_ir,
detect,
require_supported_scripts,
)
from app.converter.nani_compiler import compile_package
from app.converter.nani_expr import assignment, parse
from app.converter.nani_package import Package, decode_script, text_records
from app.converter.pipeline import convert_archive
from app.converter.preflight import inspect_directory, probe_archive
from app.converter.rpy_parser import RpyParser, ScriptData
from app.import_library import check, entries
from app.translation import LineInfo, validate_nani_translation
def write(path, text=""):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return path
def script(source):
data = ScriptData()
RpyParser(data, "script.rpy").parse(source)
return data
def package(*commands):
return Package(scripts={"Opening": {"name": "Opening", "lines": [
{"line": i, "commands": [command]} for i, command in enumerate(commands)]}})
def command(op, **args):
return {"op": op, "args": args}
@pytest.mark.parametrize("engine,marker", [
("unity", "Game_Data/Managed/Assembly-CSharp.dll"),
("unity", "Game_Data/il2cpp_data/Metadata/global-metadata.dat"),
("gamemaker", "data.win"), ("godot", "Game.pck"), ("rpgmaker", "Game.rgss3a")])
def test_unsupported_engines_fail_with_reason(tmp_path, engine, marker):
write(tmp_path / marker)
with pytest.raises(CompatibilityError) as exc:
detect(tmp_path)
assert exc.value.engine == engine
assert exc.value.report()["issues"][0]["code"] == "unsupported_engine"
def test_naninovel_detection_ignores_migration_copies(tmp_path):
write(tmp_path / "Game_Data/Managed/Elringus.Naninovel.Runtime.dll")
write(tmp_path / "migration-staging/port/project.godot")
assert detect(tmp_path).engine == "naninovel"
def test_multiple_same_engine_games_are_not_merged(tmp_path):
write(tmp_path / "one/game/script.rpy", 'label start:\n "one"')
write(tmp_path / "two/game/script.rpy", 'label start:\n "two"')
with pytest.raises(CompatibilityError, match="несколько игр"):
detect(tmp_path)
def test_renpy_only_declarations_are_not_execution_blockers(tmp_path):
source = 'screen unused():\n text "UI"\nlabel start:\n "Hello"\n return\n'
write(tmp_path / "game/script.rpy", source)
assert inspect_directory(tmp_path)["compatible"]
write(tmp_path / "game/script.rpy", source.replace(' "Hello"', ' call screen unused'))
with pytest.raises(CompatibilityError, match="call screen"):
inspect_directory(tmp_path)
@pytest.mark.parametrize("source,reason", [
('init python:\n def helper():\n yield 1\nlabel start:\n "Hi"', "init python"),
('label start:\n $ result = renpy.show_screen("map")', "renpy.show_screen"),
('label start:\n jump missing', "missing"),
])
def test_unsupported_renpy_logic_never_degrades_to_ready(source, reason):
data = script(source)
audit_ir(data)
with pytest.raises(CompatibilityError, match=reason):
require_supported_scripts(data)
def test_renpy_atl_animation_becomes_a_timeline_not_a_static_pose():
data = script('transform moving:\n xalign 0.0\n linear 1.0 xalign 1.0\n repeat\nlabel start:\n "Hi"')
require_supported_scripts(data)
assert data.transforms["moving"] == {"xalign": 0.0}
block = data.atl["moving"]
assert block["repeat"] is True
assert [step["op"] for step in block["steps"]] == ["set", "move"]
assert block["steps"][1]["props"] == {"xalign": 1.0} and block["steps"][1]["warp"] == "linear"
def test_renpy_atl_constructs_without_a_runtime_are_still_rejected():
for body in ("function slide", "time 1.0:\n xalign 1.0", "matrixcolor TintMatrix('#f00')",
"contains:\n 'a.png'"):
data = script(f'transform odd:\n {body}\nlabel start:\n "Hi"')
with pytest.raises(CompatibilityError, match="ATL"):
require_supported_scripts(data)
def test_renpy_atl_groups_events_and_named_transforms():
data = script(
"transform base:\n zoom 0.8\n"
"transform bob:\n base\n parallel:\n ease 1.0 yoffset -10\n ease 1.0 yoffset 0\n"
" repeat\n parallel:\n pause 0.5\n"
" on hide:\n linear 0.3 alpha 0.0\n"
'label start:\n "Hi"')
require_supported_scripts(data)
steps = data.atl["bob"]["steps"]
assert steps[0] == {"op": "set", "props": {"zoom": 0.8}} # known static transform is inlined
assert [s["op"] for s in steps] == ["set", "parallel", "on"]
assert len(steps[1]["blocks"]) == 2 and steps[1]["blocks"][0]["repeat"] is True
assert steps[2]["events"] == ["hide"]
def test_source_encoding_is_preserved_or_explicitly_rejected(tmp_path):
from app.converter.rpy_lexer import ParseError
from app.converter.source import read_source
source = tmp_path / "script.rpy"
source.write_bytes('# coding: cp1251\nlabel start:\n "Привет"'.encode("cp1251"))
assert "Привет" in read_source(source)
source.write_bytes(b'label start:\n "\xff"')
with pytest.raises(ParseError, match="кодировку"):
read_source(source)
def test_directory_rejection_precedes_copying_media(tmp_path, monkeypatch):
write(tmp_path / "source/game/script.rpy", 'label start:\n call screen map')
def media(*args, **kwargs):
pytest.fail("Media must not be copied/converted for an unsupported game")
monkeypatch.setattr("app.converter.pipeline.shutil.copytree", media)
monkeypatch.setattr("app.converter.pipeline.assets.convert", media)
with pytest.raises(CompatibilityError):
convert_archive(tmp_path / "source", "source", tmp_path / "work", lambda _: None)
assert (tmp_path / "source/game/script.rpy").exists()
def test_archive_probe_does_not_extract_anything(tmp_path):
source = tmp_path / "game.zip"
with zipfile.ZipFile(source, "w") as archive:
archive.writestr("Game/data.win", b"placeholder")
with pytest.raises(CompatibilityError) as exc:
probe_archive(source, source.name)
assert exc.value.engine == "gamemaker"
assert list(tmp_path.iterdir()) == [source]
def test_archive_probe_ignores_engine_runtime_scripts(tmp_path):
source = tmp_path / "game.zip"
with zipfile.ZipFile(source, "w") as archive:
archive.writestr("Game/Game_Data/Managed/Elringus.Naninovel.Runtime.dll", b"")
archive.writestr("Game/renpy/common/script.rpy", b"")
assert probe_archive(source, source.name)["engine"] == "naninovel"
def test_script_preflight_does_not_unpack_loose_media(tmp_path):
from app.converter.archive import extract
source = tmp_path / "game.zip"
with zipfile.ZipFile(source, "w") as archive:
archive.writestr("Game/game/script.rpy", 'label start:\n call screen map')
archive.writestr("Game/game/images/large.png", b"media")
destination = tmp_path / "scripts"
extract(source, source.name, destination, only_scripts=True)
assert (destination / "Game/game/script.rpy").exists()
assert not (destination / "Game/game/images").exists()
def test_batch_prefers_unpacked_duplicate_and_reports_executables(tmp_path):
(tmp_path / "Game").mkdir()
write(tmp_path / "Game.zip")
write(tmp_path / "Other.exe")
assert [p.name for p in entries(tmp_path)] == ["Game", "Other.exe"]
assert check(tmp_path / "Other.exe")["issues"][0]["code"] == "not_distribution"
def test_naninovel_decodes_line_order_and_dynamic_parameters():
tree = {"m_Name": "Opening", "lines": [{"id": 1}], "references": {"records": [
{"type": {"class": "PrintText"}, "data": {
"Text": {"hasValue": False, "value": "", "dynamicValue": {"ValueText": "Score: {score}"}},
"playbackSpot": {"lineIndex": 3}}},
{"type": {"class": "GenericTextScriptLine"}, "data": {
"lineIndex": 3, "lineHash": "original", "inlinedCommands": [{"id": 0}]}}]}}
decoded = decode_script(tree)
assert decoded["lines"][0]["commands"][0]["args"]["Text"] == {"template": "Score: {score}"}
tree["lines"][0]["id"] = -1
with pytest.raises(ValueError, match="ссылка"):
decode_script(tree)
def test_naninovel_compiles_dialogue_branches_choices_and_expressions():
original = package(command("SetCustomVariable", Expression="score=2"),
command("BeginIf", Expression="score>=1 && true"),
command("PrintText", Text={"template": "Score: {score}"}, AuthorId="Alice"),
command("Else"), command("PrintText", Text="Low"), command("EndIf"),
command("AddChoice", ChoiceSummary="Again", GotoPath="Opening"), command("Stop"))
data, entry, expressions = compile_package(original)
body = data.modules["Opening.nani"][0]["body"]
assert entry == "nani:Opening"
assert body[0]["code"][0]["v"]["k"] == "nani"
assert len(body[1]["branches"]) == 2
assert body[2]["op"] == "nani_add_choice" and body[3]["op"] == "nani_menu"
assert expressions == {"score": ["name", "score"]}
@pytest.mark.parametrize("commands,reason", [
([command("Spawn", Path="Minigame")], "Spawn"),
([command("PrintText", Text="Text", Unexpected=True)], "Unexpected"),
([command("SetCustomVariable", Expression="x=CustomFunction()")], "CustomFunction"),
([command("SetCustomVariable", Expression={"template": "x={other}"})], "Динамический"),
([command("Stop")], "внешнего интерфейса"),
([command("Goto", Path="Missing")], "цель"),
([command("BeginIf", Expression="true")], "Не закрыт"),
])
def test_naninovel_unknown_commands_functions_and_fields_are_fatal(commands, reason):
with pytest.raises(CompatibilityError, match=reason):
compile_package(package(*commands))
def test_naninovel_expression_parser_never_accepts_code():
ast = parse('score>1 ? "Yes" : "No"')
assert ast[0] == "if"
assert parse('"Привет\\n"')[1] == "Привет\n"
assert assignment("score=score+1")["v"]["k"] == "nani"
for source in ('__import__("os")', 'x.constructor()', 'x[0]', 'x; other()', 'x ? 1'):
with pytest.raises(ValueError):
parse(source)
def test_naninovel_resources_keep_keys_delimiters_and_context():
records = text_records("Tips", "# comment\nHeading\nHelp:Title|Description\n", "Tips")
assert [r["text"] for r in records.values()] == ["Title", "Description"]
assert len(text_records("_synonyms", "word=hello,hi\n", "synonyms")) == 2
duplicates = text_records("Tips", "Key:A\nKey:B", "Tips")
assert [r["text"] for r in duplicates.values()] == ["A", "B"]
assert [r["occurrence"] for r in duplicates.values()] == [0, 1]
info = LineInfo("id", "node", "script/resources.json", "resources", "", "<b>Hello {name}</b>", None,
resource={"delimiters": "|\r\n"})
validate_nani_translation(info, "<b>Привет {name}</b>")
for invalid in ("Привет {name}", "<b>Привет {other}</b>", "<b>Привет {name}|test</b>"):
with pytest.raises(ValueError):
validate_nani_translation(info, invalid)
def test_naninovel_import_localization_and_mod_editor_roundtrip(tmp_path, monkeypatch):
from app.blobs import read_bytes
from app.converter.naninovel import convert_naninovel
from app.mods import document, validate
from app.translation import apply_changes, locale_tables, text_index
monkeypatch.setenv("DATA_DIR", str(tmp_path / "library"))
get_settings.cache_clear()
original = package(command("SetCustomVariable", Expression="score=3"),
command("PrintText", Text="Hello {score}"),
command("AddChoice", ChoiceSummary="Again", GotoPath="Opening"), command("Stop"))
original.texts = text_records("Tips", "Help:Title|Description\n", "Tips")
monkeypatch.setattr("app.converter.naninovel.read_package", lambda *args, **kwargs: original)
try:
result = convert_naninovel(tmp_path, tmp_path / "work", lambda _: None)
game = json.loads(read_bytes(result.manifest["files"]["game.json"]["hash"]))
assert game["engine"] == "naninovel" and game["entry"] == "nani:Opening"
index = text_index(result.manifest)
assert len(index.lines) == 4 # dialogue, choice, two independently addressed Tips parts
line = next(info for info in index.lines if info.source == "Hello {score}")
translated = apply_changes(result.manifest, "ru", lines={line.tid: {"t": "Привет {score}"}})
assert locale_tables(translated, "ru")[0][line.tid]["t"] == "Привет {score}"
with pytest.raises(ValueError, match="выражения"):
apply_changes(result.manifest, "ru", lines={line.tid: {"t": "Привет"}})
validate(document(translated), translated)
finally:
get_settings.cache_clear()
def test_unity_codec_restores_global_hooks_after_error():
from UnityPy.helpers import TypeTreeHelper as helper
from app.converter.unity_scripts import ScriptCodec
codec = ScriptCodec.__new__(ScriptCodec)
before = (helper.read_value, helper.write_value, helper.read_typetree_boost, helper.get_ref_type_node)
with pytest.raises(RuntimeError):
with codec.active():
raise RuntimeError("broken object")
assert (helper.read_value, helper.write_value, helper.read_typetree_boost, helper.get_ref_type_node) == before
def test_unity_package_closes_cyclic_readers_and_optional_none_fields(tmp_path):
handle = (tmp_path / "asset").open("wb")
reader = SimpleNamespace(stream=handle, files=None, cabs=None)
asset = SimpleNamespace(reader=reader, files=None, cabs={"same": reader})
environment = SimpleNamespace(files={"asset": asset}, cabs={"asset": asset})
original = Package(environments=[environment], resources=[asset])
original.close()
assert handle.closed
assert not original.resources and not original.environments
(tmp_path / "asset").unlink()
def test_unused_renpy_translation_entries_are_not_script_blockers(tmp_path):
from app.converter import locales
from app.converter.rpy_parser import ScriptData
data = ScriptData()
data.modules = {"script.rpy": [{"name": "start", "body": [{"op": "say", "what": "Hello", "_ln": 1}]}]}
by_line = locales.assign_ids(data.modules)
path = tmp_path / "tl/russian/script.rpy"
path.parent.mkdir(parents=True)
path.write_text('# game/script.rpy:1\ntranslate russian start_123:\n "Привет"\n\n\n\n\n\n'
'translate russian removed_456:\n "Старая реплика"\n', encoding="utf-8")
translations = locales.import_tl(tmp_path, data.modules, by_line, data.warn)
assert translations["ru"].unmatched == 1
assert not data.blockers
assert any("реплики без пары" in message for message in data.warnings)
def test_upload_rejects_unsupported_engine_without_queueing(tmp_path, monkeypatch):
from fastapi import HTTPException
from app.routers import versions
from app.uploads import upload_path
source = tmp_path / "unsupported.zip"
with zipfile.ZipFile(source, "w") as archive:
archive.writestr("data.win", b"game")
monkeypatch.setenv("DATA_DIR", str(tmp_path / "library"))
get_settings.cache_clear()
states = []
class DB:
version = None
def add(self, version):
version.id = 123
self.version = version
async def commit(self):
states.append(self.version.status)
async def delete(self, version):
assert version.status != "queued"
async def game(*_):
return SimpleNamespace(id=1)
async def stream():
yield source.read_bytes()
monkeypatch.setattr(versions, "get_game", game)
try:
with pytest.raises(HTTPException) as exc:
asyncio.run(versions.upload_version(1, SimpleNamespace(stream=stream), SimpleNamespace(id=1), DB(), source.name))
assert exc.value.status_code == 422
assert "gamemaker" in exc.value.detail
assert "queued" not in states
assert not upload_path(123).exists()
finally:
get_settings.cache_clear()