Files
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

115 lines
4.8 KiB
Python

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"