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.
This commit is contained in:
1 parent
06ff19ac4a
commit
c8d83b3fe7
65 files changed
+3825
-68
No files matched your search
@@ -0,0 +1,114 @@
|
||||
import copy
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app import mods
|
||||
from app.config import get_settings
|
||||
from app.converter.pipeline import convert_archive
|
||||
from app.translation import blob_json, file_json
|
||||
from tests.make_sample import build
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pg_manifest(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("DATA_DIR", str(tmp_path / "data"))
|
||||
get_settings.cache_clear()
|
||||
blob_json.cache_clear()
|
||||
yield convert_archive(build(tmp_path / "sample.zip"), "sample.zip", tmp_path / "work", lambda _: None).manifest
|
||||
blob_json.cache_clear()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def node(id, text):
|
||||
return {"id": id, "op": "say", "tid": "t_" + id, "what": text}
|
||||
|
||||
|
||||
def merge(base, left, right, choices=None):
|
||||
conflicts = []
|
||||
out = mods.merge_value(base, left, right, "", conflicts, choices or {})
|
||||
return out, conflicts
|
||||
|
||||
|
||||
def test_editor_roundtrip_validation_and_anchors(pg_manifest):
|
||||
doc = mods.prepare(mods.document(pg_manifest))
|
||||
assert doc == mods.prepare(mods.document(pg_manifest))
|
||||
mods.validate(doc, pg_manifest)
|
||||
for module in doc["scripts"].values():
|
||||
if module["labels"]:
|
||||
module["labels"][0]["body"].insert(0, node(str(uuid4()), "Inserted"))
|
||||
updated = mods.store(doc, pg_manifest)
|
||||
assert mods.comparable(mods.document(updated))["scripts"] == mods.comparable(doc)["scripts"]
|
||||
assert file_json(pg_manifest, "game.json") == doc["game"]
|
||||
damaged = copy.deepcopy(doc)
|
||||
first = next(m for m in damaged["scripts"].values() if m["labels"])["labels"][0]
|
||||
first["body"].append(copy.deepcopy(first["body"][0]))
|
||||
with pytest.raises(ValueError, match="ID"):
|
||||
mods.validate(damaged, pg_manifest)
|
||||
damaged = copy.deepcopy(doc)
|
||||
next(m for m in damaged["scripts"].values() if m["labels"])["labels"][0]["body"].append({"op": "jump", "id": "bad", "target": "missing_label"})
|
||||
with pytest.raises(ValueError, match="метку"):
|
||||
mods.validate(damaged, pg_manifest)
|
||||
|
||||
|
||||
def test_merge_disjoint_fields_and_insertions():
|
||||
base = [node("a", "A"), node("b", "B"), node("c", "C")]
|
||||
left = [node("a", "Changed"), node("x", "X"), node("b", "B"), node("c", "C")]
|
||||
right = [node("a", "A"), node("b", "B"), node("y", "Y"), node("c", "Changed too")]
|
||||
result, conflicts = merge(base, left, right)
|
||||
assert not conflicts
|
||||
assert [n["id"] for n in result] == ["a", "x", "b", "y", "c"]
|
||||
assert result[0]["what"] == "Changed" and result[-1]["what"] == "Changed too"
|
||||
|
||||
|
||||
def test_conflicting_edit_delete_and_order():
|
||||
base = [node("a", "A"), node("b", "B")]
|
||||
left = [node("a", "LEFT"), node("b", "B")]
|
||||
right = [node("b", "B")]
|
||||
_, conflicts = merge(base, left, right)
|
||||
assert conflicts[0]["path"] == "/@a" and conflicts[0]["rightDeleted"]
|
||||
result, conflicts = merge(base, left, right, {"/@a": "right"})
|
||||
assert result == right and not conflicts
|
||||
_, conflicts = merge(base, [base[0], node("x", "X"), base[1]], [base[0], node("y", "Y"), base[1]])
|
||||
assert conflicts[0]["path"] == "/$order"
|
||||
|
||||
|
||||
def test_asset_conflict_keeps_hash_size_atomic():
|
||||
base = {"hash": "a", "size": 10}
|
||||
left = {"hash": "b", "size": 10}
|
||||
right = {"hash": "c", "size": 20}
|
||||
assert merge(base, left, right, {"": "left"}) == (left, [])
|
||||
|
||||
|
||||
def test_malformed_menu_condition_rejected(pg_manifest):
|
||||
doc = mods.prepare(mods.document(pg_manifest))
|
||||
from app.converter.locales import walk
|
||||
menu = next(n for m in doc["scripts"].values() for l in m["labels"] for n in walk(l["body"]) if n["op"] == "menu")
|
||||
menu["items"][0]["if"] = {"k": "run-javascript"}
|
||||
with pytest.raises(ValueError, match="выражение"):
|
||||
mods.validate(doc, pg_manifest)
|
||||
|
||||
|
||||
def test_optional_nani_expression_contract():
|
||||
from app.pgvn import expression
|
||||
expression({"k": "nani", "ast": ["bin", "+", ["name", "score"], ["const", 1]]})
|
||||
with pytest.raises(ValueError):
|
||||
expression({"k": "nani", "ast": ["call", "eval", [["const", "alert(1)"]]]})
|
||||
|
||||
|
||||
def test_rebase_does_not_trust_positional_ids():
|
||||
def doc(nodes):
|
||||
return {"game": {}, "scripts": {"script/a.json": {"labels": [{"id": "label", "name": "start", "body": nodes}]}}}
|
||||
base = doc([node("1", "A"), node("2", "B")])
|
||||
target = doc([node("1", "Inserted"), node("2", "A"), node("3", "B")])
|
||||
aligned, unmatched = mods.align(base, target)
|
||||
assert not unmatched
|
||||
nodes = aligned["scripts"]["script/a.json"]["labels"][0]["body"]
|
||||
assert nodes[0]["id"] not in ("1", "2")
|
||||
assert [n["id"] for n in nodes[1:]] == ["1", "2"]
|
||||
assert aligned == mods.align(base, target)[0]
|
||||
target["scripts"]["script/a.json"]["labels"][0]["body"][1]["what"] = "Updated A"
|
||||
_, unmatched = mods.align(base, target)
|
||||
assert unmatched[0]["id"] == "1"
|
||||
aligned, _ = mods.align(base, target, {"1": "2"})
|
||||
assert aligned["scripts"]["script/a.json"]["labels"][0]["body"][1]["id"] == "1"
|
||||
Reference in new issue
Block a user