Files
pgames-manager/backend/tests/test_compatibility.py
T
mrleo1nid c8d83b3fe7 feat: add mod editing, revision merges and secure game storage
Add immutable revisions, PGVN editor, asset replacement, merge and rebase workflows, authenticated media delivery and reachability GC. Harden the script interpreter, pin unrpyc and remove tracked build metadata. Include current compatibility checks, Naninovel import and regression tests.
2026-10-25 04:42:48 +03:00

280 lines
13 KiB
Python

import asyncio
import json
from pathlib import Path
from types import SimpleNamespace
import zipfile
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 pass\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_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
with pytest.raises(ValueError, match="Повторяющийся"):
text_records("DefaultUI", "Key:A\nKey:B", "UI")
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.translation import apply_changes, locale_tables, text_index
from app.mods import document, validate
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_upload_rejects_unsupported_engine_without_queueing(tmp_path, monkeypatch):
from app.routers import versions
from app.uploads import upload_path
from fastapi import HTTPException
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()