- FastAPI backend: admin bootstrap with forced credential change, admin-created users with one-time generated passwords, roles as permission sets - Games and versions with content-addressed blob storage; forks copy only the manifest, so mods share assets with the original - Worker: archive/RPA extraction, unrpyc for .rpyc, own .rpy parser, Python subset translated via ast, media converted to WebP/Opus/WebM - React panel (library search, game page, upload, users, roles) and a framework-free web player with saves, history, skip/auto, transitions - docker compose: postgres, backend, worker, nginx on a single HTTP port Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
82 lines
3.0 KiB
Python
82 lines
3.0 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("lambda: 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 body[0] == {"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 pass\n x = 1\nlabel start:\n $ y = x\n")
|
|
assert d.warnings and d.init_code[0]["op"] == "set"
|
|
|
|
|
|
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
|