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.
280 lines
13 KiB
Python
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()
|