- Rate-limit failed logins (8 per 15 min per IP or username, 429 + Retry-After) - Add ruff/mypy config and GitHub Actions workflow (ruff, pytest, tsc, tests, build) - Apply ruff autofixes (import order, pyupgrade) - Extract rpy_helpers.py, engine-util.ts, TranslationParts.tsx and translation-types.ts from oversized modules - Add dependency-free React UI tests - Refresh README and ARCHITECTURE (Naninovel/Tomie, status and plans) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
98 lines
4.7 KiB
Python
98 lines
4.7 KiB
Python
import json
|
|
import os
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from app.converter.nani_expr import assignment, parse
|
|
from app.converter.tomie_profile import (
|
|
ASSEMBLY_HASH,
|
|
FUNCTIONS,
|
|
constant_value,
|
|
extract_tables,
|
|
identify,
|
|
)
|
|
|
|
|
|
def instruction(op, operand=None):
|
|
return SimpleNamespace(mnemonic=op, operand=operand)
|
|
|
|
|
|
def test_il_table_reader_accepts_constants_and_refuses_code():
|
|
code = [instruction("ldc.i4.2"), instruction("newarr"), instruction("dup"), instruction("ldc.i4.0"),
|
|
instruction("ldstr", SimpleNamespace(rid=1)), instruction("stelem.ref"),
|
|
instruction("dup"), instruction("ldc.i4.1"), instruction("ldstr", SimpleNamespace(rid=2)), instruction("stelem.ref")]
|
|
assert constant_value(code, lambda rid: {1: "A", 2: "B"}[rid]) == ["A", "B"]
|
|
with pytest.raises(ValueError, match="инициализация"):
|
|
constant_value([instruction("call")], lambda _: "")
|
|
with pytest.raises(ValueError, match="Неполная"):
|
|
constant_value([instruction("ldc.i4.1"), instruction("newarr")], lambda _: "")
|
|
|
|
|
|
def test_tomie_profile_requires_exact_assembly(tmp_path):
|
|
path = tmp_path / "TomieWGM_Data/Managed/Assembly-CSharp.dll"
|
|
path.parent.mkdir(parents=True)
|
|
path.write_bytes(b"a renamed unrelated game")
|
|
assert identify(tmp_path) is None
|
|
with pytest.raises(ValueError, match="не соответствует"):
|
|
extract_tables(tmp_path)
|
|
|
|
|
|
def test_profile_expression_functions_are_opt_in():
|
|
with pytest.raises(ValueError, match="WillpowerDamage"):
|
|
parse("WillpowerDamage(100, 50, 20, 0, 100)")
|
|
assert parse("WillpowerDamage(100, 50, 20, 0, 100)", FUNCTIONS)[0] == "call"
|
|
assert parse('\\"quoted\\"', FUNCTIONS) == ["const", "quoted"]
|
|
assert parse('If(false, 1/0, 5)', FUNCTIONS)[0] == "if"
|
|
assert assignment("points+=3", FUNCTIONS)["v"]["ast"] == ["bin", "+", ["name", "points"], ["const", 3]]
|
|
|
|
|
|
def test_full_vm_validation_rejects_unimplemented_and_broken_commands():
|
|
from app.converter.tomie_validation import validate_runtime
|
|
data = {"scripts": {"Main": {"labels": {"Start": 0}, "commands": [
|
|
{"op": "AddChoice", "args": {"ChoiceSummary": "OK", "OnSelected": ['@set result=If(true, 1, 2)']}, "line": 1},
|
|
{"op": "Goto", "args": {"Path": ".Missing"}, "line": 2},
|
|
{"op": "PlayBgm", "args": {"BgmPath": "song", "CustomPlugin": True}, "line": 3},
|
|
{"op": "NativeCode", "args": {}, "line": 4},
|
|
{"op": "BeginIf", "args": {"Expression": "true"}, "line": 5},
|
|
{"op": "AddChoice", "args": {"OnSelected": ['@set points+1.5']}, "line": 6},
|
|
]}}}
|
|
issues = validate_runtime(data)
|
|
assert {item.code for item in issues} == {"missing_target", "unsupported_argument", "unsupported_command", "invalid_branch", "unsupported_choice"}
|
|
assert not any(item.location == "Main:1" for item in issues)
|
|
|
|
|
|
def test_choice_templates_and_quoted_commas_are_validated_without_evaluating_them():
|
|
from app.converter.tomie_validation import inline_commands, validate_runtime
|
|
assert inline_commands('@set x=If(true, "a,b", "c"), @return') == ['@set x=If(true, "a,b", "c")', '@return']
|
|
data = {"scripts": {"Main": {"labels": {}, "commands": [
|
|
{"op": "AddChoice", "args": {"OnSelected": {"template": '@set result="{answer}"'}}},
|
|
{"op": "AddChoice", "args": {"OnSelected": ['@goto Other.Start']}},
|
|
]}, "Other": {"labels": {"Start": 0}, "commands": []}}}
|
|
assert validate_runtime(data) == []
|
|
with pytest.raises(ValueError, match="Незакрытая"):
|
|
inline_commands('@set x=If(true, 1')
|
|
|
|
|
|
def test_native_effect_and_ui_extensions_are_rejected_before_media_export():
|
|
from app.converter.tomie_validation import validate_runtime
|
|
data = {"scripts": {"Main": {"labels": {}, "commands": [
|
|
{"op": "Spawn", "args": {"Path": "NativePlugin"}},
|
|
{"op": "ShowUI", "args": {"UINames": ["CustomScreen"]}},
|
|
{"op": "EndIf", "args": {}},
|
|
]}}}
|
|
assert {issue.code for issue in validate_runtime(data)} == {"unsupported_effect", "unsupported_ui"}
|
|
|
|
|
|
@pytest.mark.skipif(not os.getenv("PGAMES_TOMIE_ORIGINAL"), reason="Requires the user's original Tomie build")
|
|
def test_original_il_tables_match_the_recovered_port():
|
|
from hashlib import sha256
|
|
root = Path(os.environ["PGAMES_TOMIE_ORIGINAL"])
|
|
assert sha256((root / "TomieWGM_Data/Managed/Assembly-CSharp.dll").read_bytes()).hexdigest() == ASSEMBLY_HASH
|
|
tables = extract_tables(root)
|
|
assert {key: len(value) for key, value in tables.items()} == {"names": 2738, "normalComment": 55, "lewdComment": 181, "videos": 191}
|
|
if os.getenv("PGAMES_TOMIE_PORT"):
|
|
reference = json.loads((Path(os.environ["PGAMES_TOMIE_PORT"]) / "story/function_data.json").read_text(encoding="utf-8"))
|
|
assert tables == reference
|