feat: converter accepts the constructs of goodluke and SaintlyCorruption
`init -490 screen`, `import x as y` and `from m import A as B`, classes with @renpy.pure, pickle hooks
on classes, `*` items in tuples and lists, generator functions (yield statements; the player runs them
to the end), multi-line expressions that need brackets, Null() and Animation() images, images given as
expressions (`"path/{}.png".format(x)`, f-strings), window statements in init, `offset` in ATL, input
widgets with their values and the focus/confirm actions, `continue` and `break` in screens, `drag`
and DiamondBox-style `fixed` with cell_size, `getattr(...)()` calls, transforms called with arguments,
and the whitelist for the new runtime. JSON files of a game are shipped as data files. Also fixes
blocking_names failing on a call of a call during full conversion.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f2fcd05467
commit
e66160cffa
12 files changed
+176
-31
No files matched your search
@@ -223,6 +223,8 @@ class AtlParser:
|
|||||||
if key == "offset": # `offset (x, y)` is xoffset and yoffset together
|
if key == "offset": # `offset (x, y)` is xoffset and yoffset together
|
||||||
if isinstance(value, (list, tuple)) and len(value) == 2:
|
if isinstance(value, (list, tuple)) and len(value) == 2:
|
||||||
out["xoffset"], out["yoffset"] = value
|
out["xoffset"], out["yoffset"] = value
|
||||||
|
elif isinstance(value, dict) and value.get("e", {}).get("k") in ("tuple", "list") and len(value["e"]["v"]) == 2: # `offset (0, -LIFT)`: each half is computed
|
||||||
|
out["xoffset"], out["yoffset"] = ({"e": half} for half in value["e"]["v"])
|
||||||
else:
|
else:
|
||||||
self.warn("Не поддерживается ATL: offset не пара чисел", line)
|
self.warn("Не поддерживается ATL: offset не пара чисел", line)
|
||||||
continue
|
continue
|
||||||
@@ -307,7 +309,7 @@ class AtlParser:
|
|||||||
seconds = self.number(first, line) # `delays[1]`: a number picked from a list
|
seconds = self.number(first, line) # `delays[1]`: a number picked from a list
|
||||||
if seconds is not None:
|
if seconds is not None:
|
||||||
steps.append({"op": "pause", "t": seconds})
|
steps.append({"op": "pause", "t": seconds})
|
||||||
elif first in POSITION_PROPS or ((g := GLUED.match(first)) and g[1] in POSITION_PROPS):
|
elif first in POSITION_PROPS or first == "offset" or ((g := GLUED.match(first)) and g[1] in POSITION_PROPS):
|
||||||
props = self.props(parts, line)
|
props = self.props(parts, line)
|
||||||
if props:
|
if props:
|
||||||
steps.append({"op": "set", "props": props})
|
steps.append({"op": "set", "props": props})
|
||||||
|
|||||||
@@ -124,6 +124,15 @@ CALLS |= set(NATIVE_CLASSES)
|
|||||||
CALLS |= {"renpy.Render", "renpy.render", "renpy.redraw", "renpy.displayable", "renpy.IgnoreEvent",
|
CALLS |= {"renpy.Render", "renpy.render", "renpy.redraw", "renpy.displayable", "renpy.IgnoreEvent",
|
||||||
"renpy.display.layout.LiveComposite", "renpy.display.layout.Fixed", "pygame.key.get_mods",
|
"renpy.display.layout.LiveComposite", "renpy.display.layout.Fixed", "pygame.key.get_mods",
|
||||||
"pygame.mouse.get_pressed", "pygame.mouse.get_pos"}
|
"pygame.mouse.get_pressed", "pygame.mouse.get_pos"}
|
||||||
|
CALLS |= {"ConditionSwitch", "DynamicImage", "renpy.register_sl_displayable", "renpy.capture_focus", "renpy.clear_capture_focus", "renpy.get_widget", "renpy.display.focus.set_focused",
|
||||||
|
"renpy.file", "renpy.open_file", "loads", "dumps", "json.loads", "json.dumps", "Animation", "im.matrix", "renpy.context", "renpy.call_in_new_context", "renpy.get_focus_rect", "gui.rebuild", "FontGroup", "RenpyText",
|
||||||
|
"MatrixColor", "im.Image", "im.AlphaMask", "GLMovie", "renpy.display.im.Image", "colorsys.rgb_to_hls",
|
||||||
|
"colorsys.hls_to_rgb"}
|
||||||
|
CALLS |= {"next", "repr", "dict.fromkeys", "colorsys.rgb_to_hsv", "colorsys.hsv_to_rgb", "fnmatch.fnmatch",
|
||||||
|
"traceback.format_exc", "traceback.print_exc", "math.log1p", "math.expm1", "renpy.in_rollback",
|
||||||
|
"renpy.is_in_test", "renpy.retain_after_load", "renpy.context_nesting_level", "renpy.current_screen",
|
||||||
|
"renpy.substitutions.substitute", "renpy.set_screen_variable", "renpy.music.register_channel",
|
||||||
|
"renpy.music.get_loop", "renpy.music.get_channel"}
|
||||||
CALLS |= {"Matrix", "renpy.has_live2d", "renpy.fetch", "renpy.display.draw.kill_textures",
|
CALLS |= {"Matrix", "renpy.has_live2d", "renpy.fetch", "renpy.display.draw.kill_textures",
|
||||||
"renpy.display.render.free_memory", "cache.get_total_size"}
|
"renpy.display.render.free_memory", "cache.get_total_size"}
|
||||||
CALLS |= {"Move", "absolute", "type", "id", "vars", "Flatten", "LiveComposite", "Composite", "defaultdict", "OrderedDict", "deepcopy",
|
CALLS |= {"Move", "absolute", "type", "id", "vars", "Flatten", "LiveComposite", "Composite", "defaultdict", "OrderedDict", "deepcopy",
|
||||||
@@ -149,6 +158,10 @@ SCREEN_CALLS |= set("""FileAction FileSave FileLoad FileDelete FileTakeScreensho
|
|||||||
FileLoadable FileUsedSlot FileNewest FileSaveName FileTime FileJson FileScreenshot FileCurrentScreenshot
|
FileLoadable FileUsedSlot FileNewest FileSaveName FileTime FileJson FileScreenshot FileCurrentScreenshot
|
||||||
FileCurrentPage FilePageName FileSlotName""".split())
|
FileCurrentPage FilePageName FileSlotName""".split())
|
||||||
SCREEN_CALLS |= {"renpy.translation.translate_string"}
|
SCREEN_CALLS |= {"renpy.translation.translate_string"}
|
||||||
|
# Text fields and the actions around them (see InputValue in screens.ts)
|
||||||
|
SCREEN_CALLS |= set("""VariableInputValue ScreenVariableInputValue FieldInputValue DictInputValue FilePageNameInputValue
|
||||||
|
CurrentScreenName CaptureFocus ClearFocus InvertSelected UploadSync DownloadSync Confirm""".split())
|
||||||
|
METHODS_INPUT = {"Enable", "Disable", "Toggle"}
|
||||||
# Screen language helpers for audio, focus and pictures that play (see screens.ts)
|
# Screen language helpers for audio, focus and pictures that play (see screens.ts)
|
||||||
SCREEN_CALLS |= {"Movie", "MixerValue", "AudioPositionValue", "PauseAudio", "ToggleSetMembership",
|
SCREEN_CALLS |= {"Movie", "MixerValue", "AudioPositionValue", "PauseAudio", "ToggleSetMembership",
|
||||||
"GetFocusRect", "ToggleFocus", "XScrollValue", "YScrollValue", "Preference", "Start"}
|
"GetFocusRect", "ToggleFocus", "XScrollValue", "YScrollValue", "Preference", "Start"}
|
||||||
@@ -173,7 +186,7 @@ METHODS = set("append extend insert pop remove index count reverse sort clear co
|
|||||||
|
|
||||||
METHODS |= NATIVE_METHODS
|
METHODS |= NATIVE_METHODS
|
||||||
# Methods of generators made by `renpy.random.Random()` / `random.Random()`
|
# Methods of generators made by `renpy.random.Random()` / `random.Random()`
|
||||||
METHODS |= {"blit", "subpixel_blit", "fill", "canvas", "get_size", "add_shader", "add_uniform", "add_property",
|
METHODS |= {"add_style_property", "add_prefix_style_property", "add_pattern", "get_context", "read", "readlines", "close", "sub", "subn", "search", "match", "findall", "finditer", "split", "time", "_clear", "Enable", "Disable", "Toggle", "encode", "blit", "subpixel_blit", "fill", "canvas", "get_size", "add_shader", "add_uniform", "add_property",
|
||||||
"add_focus", "circle", "rect", "line", "lines", "polygon"}
|
"add_focus", "circle", "rect", "line", "lines", "polygon"}
|
||||||
METHODS |= {"fullmatch", "hexdigest", "randint", "randrange", "random", "choice", "choices", "sample", "shuffle", "uniform"}
|
METHODS |= {"fullmatch", "hexdigest", "randint", "randrange", "random", "choice", "choices", "sample", "shuffle", "uniform"}
|
||||||
|
|
||||||
@@ -380,7 +393,8 @@ def blocking_names(data) -> list[str]:
|
|||||||
elif isinstance(value, dict):
|
elif isinstance(value, dict):
|
||||||
if value.get("k") == "call":
|
if value.get("k") == "call":
|
||||||
function = value["f"]
|
function = value["f"]
|
||||||
last = function.get("id") if function.get("k") == "name" else function.get("a")
|
last = function.get("id") if function.get("k") == "name" else (
|
||||||
|
function.get("a") if function.get("k") == "attr" else None) # `f()()` has neither
|
||||||
dotted = (function["o"]["id"] + "." + function["a"]) if (function.get("k") == "attr" and
|
dotted = (function["o"]["id"] + "." + function["a"]) if (function.get("k") == "attr" and
|
||||||
function["o"].get("k") == "name") else last
|
function["o"].get("k") == "name") else last
|
||||||
if dotted in waiting or last in known or last in waiting:
|
if dotted in waiting or last in known or last in waiting:
|
||||||
@@ -504,6 +518,7 @@ def audit_ir(data) -> None:
|
|||||||
list(used_screens.values())])
|
list(used_screens.values())])
|
||||||
user_names |= {name for name, _ in data.defaults}
|
user_names |= {name for name, _ in data.defaults}
|
||||||
user_names |= set(data.characters) | {"narrator"}
|
user_names |= set(data.characters) | {"narrator"}
|
||||||
|
user_names |= set(data.transforms) | set(data.atl) # `store.move_in(0.3)`: a transform called with arguments
|
||||||
|
|
||||||
def dotted(value):
|
def dotted(value):
|
||||||
if value.get("k") == "name":
|
if value.get("k") == "name":
|
||||||
@@ -524,8 +539,9 @@ def audit_ir(data) -> None:
|
|||||||
and (function.get("a") in METHODS or function.get("a") in user_methods
|
and (function.get("a") in METHODS or function.get("a") in user_methods
|
||||||
or function.get("a") in user_names))
|
or function.get("a") in user_names))
|
||||||
# `MainMenu(confirm=False)()`: calling the action a screen action constructor returned
|
# `MainMenu(confirm=False)()`: calling the action a screen action constructor returned
|
||||||
if function.get("k") == "call" and dotted(function.get("f", {})) in SCREEN_CALLS:
|
if function.get("k") == "call" and (dotted(function.get("f", {})) in SCREEN_CALLS
|
||||||
is_method = True
|
or dotted(function.get("f", {})) == "getattr"):
|
||||||
|
is_method = True # an action a constructor returned, or `getattr(obj, "f", fallback)()`
|
||||||
# `globals()[who + "_here"](...)`: a function picked by name; some function must carry that ending
|
# `globals()[who + "_here"](...)`: a function picked by name; some function must carry that ending
|
||||||
if function.get("k") == "sub" and function["o"].get("k") == "call" \
|
if function.get("k") == "sub" and function["o"].get("k") == "call" \
|
||||||
and dotted(function["o"]["f"]) == "globals":
|
and dotted(function["o"]["f"]) == "globals":
|
||||||
|
|||||||
@@ -162,6 +162,16 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
|
|||||||
raise CompatibilityError("renpy", [Issue("media_conversion", "Не удалось конвертировать обязательное медиа", name)
|
raise CompatibilityError("renpy", [Issue("media_conversion", "Не удалось конвертировать обязательное медиа", name)
|
||||||
for name in failed])
|
for name in failed])
|
||||||
|
|
||||||
|
# JSON the game reads itself (`renpy.file("data/x.json").read()`): kept as it is, loaded before the game starts
|
||||||
|
data_files: list[str] = []
|
||||||
|
for p in sorted(game_dir.rglob("*.json")):
|
||||||
|
relative = p.relative_to(game_dir).as_posix()
|
||||||
|
if _is_engine_file(game_dir, p) or relative.lower().startswith(("cache/", "saves/")) or p.stat().st_size > DATA_FILE_LIMIT:
|
||||||
|
continue
|
||||||
|
blob_id, size = put_file(p, ".json")
|
||||||
|
files[f"data/{relative}"] = {"hash": blob_id, "size": size}
|
||||||
|
data_files.append(relative)
|
||||||
|
|
||||||
progress("Сборка формата PGVN")
|
progress("Сборка формата PGVN")
|
||||||
_auto_images(data, aliases)
|
_auto_images(data, aliases)
|
||||||
missing: set[str] = set()
|
missing: set[str] = set()
|
||||||
@@ -212,6 +222,7 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
|
|||||||
"channels": data.channels,
|
"channels": data.channels,
|
||||||
"build": data.build,
|
"build": data.build,
|
||||||
"files": aliases,
|
"files": aliases,
|
||||||
|
"dataFiles": data_files,
|
||||||
"defaults": data.defaults,
|
"defaults": data.defaults,
|
||||||
"init": data.init_code,
|
"init": data.init_code,
|
||||||
"ui": _ui(gui, aliases),
|
"ui": _ui(gui, aliases),
|
||||||
@@ -328,6 +339,9 @@ def _used_screens(data: ScriptData) -> set[str]:
|
|||||||
return used
|
return used
|
||||||
|
|
||||||
|
|
||||||
|
DATA_FILE_LIMIT = 4 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
def _is_engine_file(game_dir: Path, p: Path) -> bool:
|
def _is_engine_file(game_dir: Path, p: Path) -> bool:
|
||||||
parts = [x.lower() for x in p.relative_to(game_dir).parts]
|
parts = [x.lower() for x in p.relative_to(game_dir).parts]
|
||||||
return len(parts) > 1 and parts[0] in ENGINE_DIRS
|
return len(parts) > 1 and parts[0] in ENGINE_DIRS
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ UNARY_OPS = {ast.Not: "not", ast.USub: "-", ast.UAdd: "+", ast.Invert: "~"}
|
|||||||
|
|
||||||
# Modules whose import is harmless: the player provides the functions it supports.
|
# Modules whose import is harmless: the player provides the functions it supports.
|
||||||
KNOWN_MODULES = {"random", "math", "renpy", "store", "time", "datetime", "re", "copy", "collections",
|
KNOWN_MODULES = {"random", "math", "renpy", "store", "time", "datetime", "re", "copy", "collections",
|
||||||
"itertools", "string", "functools", "operator", "json", "gc", "hashlib", "uuid", "pygame"}
|
"itertools", "string", "functools", "operator", "json", "gc", "hashlib", "uuid", "pygame", "colorsys", "fnmatch", "traceback", "builtins"}
|
||||||
|
|
||||||
|
|
||||||
class Unsupported(Exception):
|
class Unsupported(Exception):
|
||||||
@@ -66,9 +66,14 @@ def parse_expr(source: str) -> Expr:
|
|||||||
try:
|
try:
|
||||||
tree = ast.parse(source.strip(), mode="eval")
|
tree = ast.parse(source.strip(), mode="eval")
|
||||||
except SyntaxError as e:
|
except SyntaxError as e:
|
||||||
try:
|
# a long expression continued on the next lines (`A | B`, `FontGroup().add(...)`) needs brackets
|
||||||
tree = ast.parse(python2_operators(source).strip(), mode="eval")
|
for candidate in (python2_operators(source), "(" + source.strip() + ")"):
|
||||||
except SyntaxError:
|
try:
|
||||||
|
tree = ast.parse(candidate.strip(), mode="eval")
|
||||||
|
break
|
||||||
|
except SyntaxError:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
raise Unsupported(f"синтаксическая ошибка в выражении: {source!r}") from e
|
raise Unsupported(f"синтаксическая ошибка в выражении: {source!r}") from e
|
||||||
return expr(tree.body)
|
return expr(tree.body)
|
||||||
|
|
||||||
@@ -142,9 +147,9 @@ def expr(node: ast.AST) -> Expr:
|
|||||||
case ast.IfExp(test=t, body=b, orelse=e):
|
case ast.IfExp(test=t, body=b, orelse=e):
|
||||||
return {"k": "if", "c": expr(t), "t": expr(b), "e": expr(e)}
|
return {"k": "if", "c": expr(t), "t": expr(b), "e": expr(e)}
|
||||||
case ast.List(elts=elts) | ast.Set(elts=elts):
|
case ast.List(elts=elts) | ast.Set(elts=elts):
|
||||||
return {"k": "list", "v": [expr(e) for e in elts]}
|
return {"k": "list", "v": [element(e) for e in elts]}
|
||||||
case ast.Tuple(elts=elts):
|
case ast.Tuple(elts=elts):
|
||||||
return {"k": "tuple", "v": [expr(e) for e in elts]}
|
return {"k": "tuple", "v": [element(e) for e in elts]}
|
||||||
case ast.Dict(keys=keys, values=values):
|
case ast.Dict(keys=keys, values=values):
|
||||||
# `{**other, "k": v}`: a spread entry is a star key with an empty value
|
# `{**other, "k": v}`: a spread entry is a star key with an empty value
|
||||||
return {"k": "dict", "v": [[{"k": "star", "v": expr(v)}, {"k": "const", "v": None}] if k is None
|
return {"k": "dict", "v": [[{"k": "star", "v": expr(v)}, {"k": "const", "v": None}] if k is None
|
||||||
@@ -208,6 +213,10 @@ def stmt(node: ast.stmt) -> list[Stmt]:
|
|||||||
return [{"op": "aug", "t": target(t), "o": BIN_OPS[type(op)], "v": expr(value)}]
|
return [{"op": "aug", "t": target(t), "o": BIN_OPS[type(op)], "v": expr(value)}]
|
||||||
case ast.Expr(value=ast.Call(func=func)) if ast.unparse(func).startswith("build."):
|
case ast.Expr(value=ast.Call(func=func)) if ast.unparse(func).startswith("build."):
|
||||||
return [] # distribution settings (build.classify, build.archive ...) have no meaning in the browser
|
return [] # distribution settings (build.classify, build.archive ...) have no meaning in the browser
|
||||||
|
case ast.Expr(value=ast.Yield(value=value)):
|
||||||
|
return [{"op": "yield", "v": None if value is None else expr(value)}]
|
||||||
|
case ast.Expr(value=ast.YieldFrom(value=value)):
|
||||||
|
return [{"op": "yield_from", "v": expr(value)}]
|
||||||
case ast.Expr(value=ast.Constant()):
|
case ast.Expr(value=ast.Constant()):
|
||||||
return [] # docstring / bare literal
|
return [] # docstring / bare literal
|
||||||
case ast.Expr(value=value):
|
case ast.Expr(value=value):
|
||||||
@@ -239,16 +248,22 @@ def stmt(node: ast.stmt) -> list[Stmt]:
|
|||||||
case ast.Import(names=names) | ast.ImportFrom(names=names):
|
case ast.Import(names=names) | ast.ImportFrom(names=names):
|
||||||
mod = getattr(node, "module", None) or names[0].name
|
mod = getattr(node, "module", None) or names[0].name
|
||||||
if mod.split(".")[0] in KNOWN_MODULES:
|
if mod.split(".")[0] in KNOWN_MODULES:
|
||||||
return []
|
# `import time as t` / `from renpy.text.text import Text as RenpyText`: the new name is the old one
|
||||||
|
return [{"op": "set", "t": {"k": "name", "id": alias.asname},
|
||||||
|
"v": {"k": "name", "id": alias.name.split(".")[0] if isinstance(node, ast.Import) else alias.name}}
|
||||||
|
for alias in names if alias.asname and alias.asname != alias.name]
|
||||||
raise Unsupported(f"импорт модуля {mod}")
|
raise Unsupported(f"импорт модуля {mod}")
|
||||||
case ast.FunctionDef(decorator_list=decos) if any(
|
case ast.FunctionDef(decorator_list=decos) if any(
|
||||||
ast.unparse(d).split("(")[0] in INACTIVE_VARIANTS for d in decos):
|
ast.unparse(d).split("(")[0] in INACTIVE_VARIANTS for d in decos):
|
||||||
return [] # screen-size/touch variants never apply to the desktop browser player
|
return [] # screen-size/touch variants never apply to the desktop browser player
|
||||||
case ast.FunctionDef(name=name, args=args, body=body, decorator_list=decos):
|
case ast.FunctionDef(name=name, args=args, body=body, decorator_list=decos):
|
||||||
decos = [d for d in decos if ast.unparse(d) not in TRANSPARENT_DECORATORS]
|
decos = [d for d in decos if ast.unparse(d) not in TRANSPARENT_DECORATORS]
|
||||||
return [{"op": "def", "name": name, "params": params(args), "body": function_body(body),
|
node = {"op": "def", "name": name, "params": params(args), "body": function_body(body),
|
||||||
"decos": [decorator(d) for d in decos]}]
|
"decos": [decorator(d) for d in decos]}
|
||||||
case ast.ClassDef(name=name, bases=bases, keywords=[], body=body, decorator_list=[]):
|
if is_generator(body):
|
||||||
|
node["gen"] = True
|
||||||
|
return [node]
|
||||||
|
case ast.ClassDef(name=name, bases=bases, keywords=[], body=body, decorator_list=decos) if all(ast.unparse(d) in TRANSPARENT_DECORATORS for d in decos):
|
||||||
return [{"op": "class", "name": name, "bases": [expr(b) for b in bases],
|
return [{"op": "class", "name": name, "bases": [expr(b) for b in bases],
|
||||||
"body": class_body(name, body)}]
|
"body": class_body(name, body)}]
|
||||||
case ast.ClassDef(name=name):
|
case ast.ClassDef(name=name):
|
||||||
@@ -264,6 +279,8 @@ def stmt(node: ast.stmt) -> list[Stmt]:
|
|||||||
|
|
||||||
# Methods the player can run implicitly. Any other dunder method would silently
|
# Methods the player can run implicitly. Any other dunder method would silently
|
||||||
# never be called, so the converter refuses it instead.
|
# never be called, so the converter refuses it instead.
|
||||||
|
# Pickle hooks only matter for Ren'Py's own save files; the player keeps its own
|
||||||
|
IGNORED_DUNDERS = {"__setstate__", "__getstate__", "__reduce__", "__reduce_ex__"}
|
||||||
SUPPORTED_DUNDERS = {"__init__", "__str__", "__repr__", "__call__", "__eq__", "__hash__", "__iter__", "__getitem__",
|
SUPPORTED_DUNDERS = {"__init__", "__str__", "__repr__", "__call__", "__eq__", "__hash__", "__iter__", "__getitem__",
|
||||||
"__contains__"}
|
"__contains__"}
|
||||||
INACTIVE_VARIANTS = {"gui.variant", "renpy.variant"}
|
INACTIVE_VARIANTS = {"gui.variant", "renpy.variant"}
|
||||||
@@ -296,19 +313,39 @@ def decorator(node: ast.expr) -> Expr:
|
|||||||
raise Unsupported(f"декоратор {ast.unparse(node)}")
|
raise Unsupported(f"декоратор {ast.unparse(node)}")
|
||||||
|
|
||||||
|
|
||||||
|
def is_generator(body: list[ast.stmt]) -> bool:
|
||||||
|
"""A function with `yield` statements. The player runs it to the end and hands out what it yielded, which is
|
||||||
|
right for generators that end; `x = yield` (sending values in) and async code stay unsupported."""
|
||||||
|
module = ast.Module(body=body, type_ignores=[])
|
||||||
|
statement_yields = {id(n.value) for n in ast.walk(module)
|
||||||
|
if isinstance(n, ast.Expr) and isinstance(n.value, (ast.Yield, ast.YieldFrom))}
|
||||||
|
found = False
|
||||||
|
for n in ast.walk(module):
|
||||||
|
if isinstance(n, ast.Await):
|
||||||
|
raise Unsupported("async")
|
||||||
|
if isinstance(n, (ast.Yield, ast.YieldFrom)):
|
||||||
|
if id(n) not in statement_yields:
|
||||||
|
raise Unsupported("yield как выражение")
|
||||||
|
found = True
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
def function_body(body: list[ast.stmt]) -> list[Stmt]:
|
def function_body(body: list[ast.stmt]) -> list[Stmt]:
|
||||||
for n in ast.walk(ast.Module(body=body, type_ignores=[])):
|
is_generator(body)
|
||||||
if isinstance(n, (ast.Yield, ast.YieldFrom, ast.Await)):
|
|
||||||
raise Unsupported("генераторы и async")
|
|
||||||
return stmts(body)
|
return stmts(body)
|
||||||
|
|
||||||
|
|
||||||
|
def element(node: ast.expr) -> Expr:
|
||||||
|
"""One item of a list or tuple; `*rest` spreads a sequence into it."""
|
||||||
|
return {"k": "star", "v": expr(node.value)} if isinstance(node, ast.Starred) else expr(node)
|
||||||
|
|
||||||
|
|
||||||
def class_body(name: str, body: list[ast.stmt]) -> list[Stmt]:
|
def class_body(name: str, body: list[ast.stmt]) -> list[Stmt]:
|
||||||
for n in body:
|
for n in body:
|
||||||
if (isinstance(n, ast.FunctionDef) and n.name.startswith("__") and n.name.endswith("__")
|
if (isinstance(n, ast.FunctionDef) and n.name.startswith("__") and n.name.endswith("__")
|
||||||
and n.name not in SUPPORTED_DUNDERS):
|
and n.name not in SUPPORTED_DUNDERS | IGNORED_DUNDERS):
|
||||||
raise Unsupported(f"специальный метод {name}.{n.name}()")
|
raise Unsupported(f"специальный метод {name}.{n.name}()")
|
||||||
return stmts(body)
|
return stmts([n for n in body if not (isinstance(n, ast.FunctionDef) and n.name in IGNORED_DUNDERS)])
|
||||||
|
|
||||||
|
|
||||||
def generators(gens: list[ast.comprehension]) -> list[dict[str, Any]]:
|
def generators(gens: list[ast.comprehension]) -> list[dict[str, Any]]:
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ POSITION_PROPS = {"xpos", "ypos", "xanchor", "yanchor", "xalign", "yalign", "xce
|
|||||||
_SHOW_KEYWORDS = {"at", "as", "behind", "onlayer", "with", "zorder"}
|
_SHOW_KEYWORDS = {"at", "as", "behind", "onlayer", "with", "zorder"}
|
||||||
_NUMBER_LINE = re.compile(r"^\d+(\.\d*)?$|^\.\d+$")
|
_NUMBER_LINE = re.compile(r"^\d+(\.\d*)?$|^\.\d+$")
|
||||||
_CALL_LINE = re.compile(r"^([A-Za-z_][\w.]*)\s*\(")
|
_CALL_LINE = re.compile(r"^([A-Za-z_][\w.]*)\s*\(")
|
||||||
_DISPLAYABLE_CALLS = {"Movie", "Solid", "Transform", "Composite", "LiveComposite", "Fixed",
|
_DISPLAYABLE_CALLS = {"Null", "Movie", "Solid", "Transform", "Composite", "LiveComposite", "Fixed",
|
||||||
"ConditionSwitch", "TransitionConditionSwitch", "Image", "Text", "AlphaMask", "im.Scale", "im.FactorScale", "im.Flip",
|
"ConditionSwitch", "TransitionConditionSwitch", "Image", "Text", "AlphaMask", "im.Scale", "im.FactorScale", "im.Flip",
|
||||||
"im.Composite", "im.Crop", "im.Grayscale", "im.Sepia", "Frame", "Flatten"}
|
"im.Composite", "im.Crop", "im.Grayscale", "im.Sepia", "Frame", "Flatten"}
|
||||||
_MEDIA_EXTS = {"png", "jpg", "jpeg", "webp", "gif", "bmp", "avif", "ogg", "mp3", "wav", "opus",
|
_MEDIA_EXTS = {"png", "jpg", "jpeg", "webp", "gif", "bmp", "avif", "ogg", "mp3", "wav", "opus",
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ class ScriptData:
|
|||||||
self.warning_examples[kind] = where
|
self.warning_examples[kind] = where
|
||||||
|
|
||||||
|
|
||||||
|
LAZY_IMAGE_KINDS = {"fmt", "bin", "name", "attr", "sub", "if"}
|
||||||
LIVE2D_IF = re.compile(r"^\s*if\s+renpy\.has_live2d\(\)(?:\s+and\s.*)?:\s*$")
|
LIVE2D_IF = re.compile(r"^\s*if\s+renpy\.has_live2d\(\)(?:\s+and\s.*)?:\s*$")
|
||||||
TRANSLATED_SPEAKER = re.compile(r"""^_{1,2}\(\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')\s*\)(?=\s+["'])""")
|
TRANSLATED_SPEAKER = re.compile(r"""^_{1,2}\(\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')\s*\)(?=\s+["'])""")
|
||||||
|
|
||||||
@@ -469,11 +470,14 @@ class RpyParser:
|
|||||||
c.keyword("python")
|
c.keyword("python")
|
||||||
self.init_python(line.block_source(), line)
|
self.init_python(line.block_source(), line)
|
||||||
return []
|
return []
|
||||||
|
rest = c.rest().strip()
|
||||||
|
if rest and rest != ":": # `init -490 screen name(...):` - one statement with a priority in front
|
||||||
|
return self.statement(replace(line, text=rest), top_level=True)
|
||||||
for child in self.fold_live2d(line.block):
|
for child in self.fold_live2d(line.block):
|
||||||
for node in self.statement(child, top_level=True):
|
for node in self.statement(child, top_level=True):
|
||||||
if node["op"] == "exec":
|
if node["op"] == "exec":
|
||||||
self.d.init_code.extend(node["code"])
|
self.d.init_code.extend(node["code"])
|
||||||
else:
|
elif node["op"] != "window": # `window hide` at init time changes nothing that is shown
|
||||||
self.d.warn("Оператор сцены внутри init", self.where(child))
|
self.d.warn("Оператор сцены внутри init", self.where(child))
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@@ -758,12 +762,15 @@ class RpyParser:
|
|||||||
return {"file": s}
|
return {"file": s}
|
||||||
return {"ref": s}
|
return {"ref": s}
|
||||||
if e["k"] != "call":
|
if e["k"] != "call":
|
||||||
self.d.warn("Изображение задано выражением", self.where(line))
|
# a name, an f-string or a sum of strings: the picture is whatever it evaluates to when it is shown
|
||||||
|
self.d.warn("Изображение задано выражением", self.where(line), blocking=e["k"] not in LAZY_IMAGE_KINDS)
|
||||||
return {"expr": e}
|
return {"expr": e}
|
||||||
fname = _dotted_name(e["f"])
|
fname = _dotted_name(e["f"])
|
||||||
args, kw = e["a"], e["kw"]
|
args, kw = e["a"], e["kw"]
|
||||||
if fname == "Solid" and args:
|
if fname == "Solid" and args:
|
||||||
return {"color": _const(args[0])}
|
return {"color": _const(args[0])}
|
||||||
|
if fname == "Null": # nothing to draw: a transparent picture
|
||||||
|
return {"color": "#00000000"}
|
||||||
if fname == "Text" and args:
|
if fname == "Text" and args:
|
||||||
return {"textExpr": args[0]}
|
return {"textExpr": args[0]}
|
||||||
if fname in ("Movie",):
|
if fname in ("Movie",):
|
||||||
@@ -815,6 +822,9 @@ class RpyParser:
|
|||||||
layers.append({"pos": list(pos), "img": sub})
|
layers.append({"pos": list(pos), "img": sub})
|
||||||
return {"composite": layers, "size": list(size) if isinstance(size, (list, tuple))
|
return {"composite": layers, "size": list(size) if isinstance(size, (list, tuple))
|
||||||
else None}
|
else None}
|
||||||
|
if fname == "Animation": # pictures that take turns, named by values known only while playing
|
||||||
|
self.d.warn("Изображение задано выражением", self.where(line), blocking=False)
|
||||||
|
return {"expr": e}
|
||||||
# wrappers: Transform("x.png", zoom=.5), im.Scale(...), im.Flip(...), etc.
|
# wrappers: Transform("x.png", zoom=.5), im.Scale(...), im.Flip(...), etc.
|
||||||
for a in args:
|
for a in args:
|
||||||
sub = self.displayable_expr(a, line) if a.get("k") in ("const", "call") else None
|
sub = self.displayable_expr(a, line) if a.get("k") in ("const", "call") else None
|
||||||
@@ -832,6 +842,10 @@ class RpyParser:
|
|||||||
if props:
|
if props:
|
||||||
sub = {**sub, "props": {**sub.get("props", {}), **props}}
|
sub = {**sub, "props": {**sub.get("props", {}), **props}}
|
||||||
return sub
|
return sub
|
||||||
|
if fname == "Animation" or e["f"].get("k") == "attr" and not fname.startswith(("im.", "renpy.")):
|
||||||
|
# `Animation(...)` and `"path/{}.png".format(x)`: built from values known only while playing
|
||||||
|
self.d.warn("Изображение задано выражением", self.where(line), blocking=False)
|
||||||
|
return {"expr": e}
|
||||||
self.d.warn(f"Изображение {fname}() не поддерживается", self.where(line))
|
self.d.warn(f"Изображение {fname}() не поддерживается", self.where(line))
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ OPENERS = {"Show", "ShowTransient", "ToggleScreen", "Hide", "show_screen", "call
|
|||||||
|
|
||||||
CONTAINERS = {"fixed", "frame", "window", "vbox", "hbox", "grid", "vpgrid", "viewport", "button", "side", "nearrect",
|
CONTAINERS = {"fixed", "frame", "window", "vbox", "hbox", "grid", "vpgrid", "viewport", "button", "side", "nearrect",
|
||||||
"drag"}
|
"drag"}
|
||||||
LEAVES = {"text", "label", "textbutton", "imagebutton", "add", "image", "null", "bar", "vbar", "mousearea"}
|
LEAVES = {"text", "label", "textbutton", "imagebutton", "add", "image", "null", "bar", "vbar", "mousearea", "input"}
|
||||||
POSITIONAL = {"side": 1, "text": 1, "label": 1, "textbutton": 1, "add": 1, "image": 1, "grid": 2}
|
POSITIONAL = {"side": 1, "text": 1, "label": 1, "textbutton": 1, "add": 1, "image": 1, "grid": 2}
|
||||||
ATL_WORDS = {"pause", "repeat", "parallel", "choice", "block", "contains", "function", "time"}
|
ATL_WORDS = {"pause", "repeat", "parallel", "choice", "block", "contains", "function", "time"}
|
||||||
|
|
||||||
@@ -50,11 +50,11 @@ BEHAVIOUR = set("""action alternate hovered unhovered sensitive tooltip focus_ma
|
|||||||
activate_sound focus keysym alt id modal zorder tag layer variant style style_prefix at value range
|
activate_sound focus keysym alt id modal zorder tag layer variant style style_prefix at value range
|
||||||
adjustment changed mousewheel draggable scrollbars xinitial yinitial child_size edgescroll arrowkeys
|
adjustment changed mousewheel draggable scrollbars xinitial yinitial child_size edgescroll arrowkeys
|
||||||
text_style properties substitute prefix suffix repeat style_group prefer_top preferred_side
|
text_style properties substitute prefix suffix repeat style_group prefer_top preferred_side
|
||||||
drag_name draggable droppable drag_handle drag_joined drag_offscreen""".split())
|
drag_name draggable droppable drag_handle drag_joined drag_offscreen cell_size fit start_lower default allow exclude mask length copypaste pixel_width multiline""".split())
|
||||||
# Scrolling widgets share state through adjustments; scrollbars are cosmetic in the browser
|
# Scrolling widgets share state through adjustments; scrollbars are cosmetic in the browser
|
||||||
IGNORED_PREFIXES = ("scrollbar_", "vscrollbar_", "bar_", "side_", "thumb_", "slider_", "vslider_", "left_gutter",
|
IGNORED_PREFIXES = ("scrollbar_", "vscrollbar_", "bar_", "side_", "thumb_", "slider_", "vslider_", "left_gutter",
|
||||||
"right_gutter", "top_gutter", "bottom_gutter")
|
"right_gutter", "top_gutter", "bottom_gutter")
|
||||||
IGNORED = {"base_bar", "subpixel", "roll_forward", "xadjustment", "yadjustment", "pagekeys", "unscrollable", "arrowkeys", "edgescroll",
|
IGNORED = {"capture", "axis", "key_events", "base_bar", "subpixel", "roll_forward", "xadjustment", "yadjustment", "pagekeys", "unscrollable", "arrowkeys", "edgescroll",
|
||||||
"child_size", "mouse", "alt", "properties", "substitute", "prefix", "suffix", "keysym"}
|
"child_size", "mouse", "alt", "properties", "substitute", "prefix", "suffix", "keysym"}
|
||||||
STATE_PREFIXES = ("idle", "hover", "insensitive", "selected", "selected_idle", "selected_hover",
|
STATE_PREFIXES = ("idle", "hover", "insensitive", "selected", "selected_idle", "selected_hover",
|
||||||
"selected_insensitive")
|
"selected_insensitive")
|
||||||
@@ -208,6 +208,8 @@ class ScreenParser:
|
|||||||
node = self.use(line, words)
|
node = self.use(line, words)
|
||||||
if node:
|
if node:
|
||||||
nodes.append(node)
|
nodes.append(node)
|
||||||
|
elif first in ("continue", "break"):
|
||||||
|
nodes.append({"c": first})
|
||||||
elif first == "transclude":
|
elif first == "transclude":
|
||||||
nodes.append({"c": "transclude"})
|
nodes.append({"c": "transclude"})
|
||||||
elif first == "pass":
|
elif first == "pass":
|
||||||
@@ -222,8 +224,10 @@ class ScreenParser:
|
|||||||
i = len(lines)
|
i = len(lines)
|
||||||
elif first == "python" and line.text.strip() == "python:" or first == "$":
|
elif first == "python" and line.text.strip() == "python:" or first == "$":
|
||||||
nodes.extend(self.python(line, first))
|
nodes.extend(self.python(line, first))
|
||||||
elif first == "default":
|
elif first == "default" and "=" in text:
|
||||||
self.default(line)
|
self.default(line)
|
||||||
|
elif first == "default": # `default "Bob"` inside an input: its starting text
|
||||||
|
props.update(self.properties(words, line))
|
||||||
elif first == "key":
|
elif first == "key":
|
||||||
node = self.key(line, words)
|
node = self.key(line, words)
|
||||||
if node:
|
if node:
|
||||||
@@ -391,6 +395,8 @@ class ScreenParser:
|
|||||||
return None
|
return None
|
||||||
if kind in ("bar", "vbar"):
|
if kind in ("bar", "vbar"):
|
||||||
props["vertical"] = {"k": "const", "v": kind == "vbar"}
|
props["vertical"] = {"k": "const", "v": kind == "vbar"}
|
||||||
|
if kind == "fixed" and "cell_size" in props: # the game's DiamondBox (decompiled as a plain `fixed`)
|
||||||
|
kind = "diamondbox"
|
||||||
node: Node = {"w": kind, "args": args, "props": props, "body": body}
|
node: Node = {"w": kind, "args": args, "props": props, "body": body}
|
||||||
if conditional:
|
if conditional:
|
||||||
node["propIf"] = conditional
|
node["propIf"] = conditional
|
||||||
@@ -419,6 +425,8 @@ class ScreenParser:
|
|||||||
if cond is not None:
|
if cond is not None:
|
||||||
return {"choose": {"c": cond, "then": self.at_ref(ast.unparse(tree.body), line),
|
return {"choose": {"c": cond, "then": self.at_ref(ast.unparse(tree.body), line),
|
||||||
"else": self.at_ref(ast.unparse(tree.orelse), line)}}
|
"else": self.at_ref(ast.unparse(tree.orelse), line)}}
|
||||||
|
if text == "None":
|
||||||
|
return {}
|
||||||
if re.fullmatch(r"[A-Za-z_]\w*(?:\.\w+)*\[[^\]]+\]|[A-Za-z_]\w*\.\w+", text): # `icon["at"]`, `item.at`
|
if re.fullmatch(r"[A-Za-z_]\w*(?:\.\w+)*\[[^\]]+\]|[A-Za-z_]\w*\.\w+", text): # `icon["at"]`, `item.at`
|
||||||
dynamic = self.expr(text, line)
|
dynamic = self.expr(text, line)
|
||||||
if dynamic is not None:
|
if dynamic is not None:
|
||||||
|
|||||||
+1
-1
@@ -127,7 +127,7 @@ def statements(code, depth=0):
|
|||||||
keys = {"set": ("t", "v"), "aug": ("t", "v"), "eval": ("e",),
|
keys = {"set": ("t", "v"), "aug": ("t", "v"), "eval": ("e",),
|
||||||
"if": ("c",), "while": ("c",), "for": ("t", "it"),
|
"if": ("c",), "while": ("c",), "for": ("t", "it"),
|
||||||
"del": ("t",), "break": (), "continue": (), "def": (), "class": (),
|
"del": ("t",), "break": (), "continue": (), "def": (), "class": (),
|
||||||
"return": (), "global": (), "nonlocal": (), "try": (), "raise": (),
|
"return": (), "global": (), "nonlocal": (), "try": (), "raise": (), "yield": (), "yield_from": ("v",),
|
||||||
"assert": ("c",)}
|
"assert": ("c",)}
|
||||||
if op not in keys:
|
if op not in keys:
|
||||||
raise ValueError(f"Неизвестный оператор AST: {op}")
|
raise ValueError(f"Неизвестный оператор AST: {op}")
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ def test_renpy_only_declarations_are_not_execution_blockers(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("source,reason", [
|
@pytest.mark.parametrize("source,reason", [
|
||||||
('init python:\n def helper():\n yield 1\nlabel start:\n $ helper()', "init python"),
|
('init python:\n def helper():\n received = yield 1\nlabel start:\n $ helper()', "init python"),
|
||||||
('label start:\n $ result = renpy.show_displayable("map")', "renpy.show_displayable"),
|
('label start:\n $ result = renpy.show_displayable("map")', "renpy.show_displayable"),
|
||||||
('label start:\n jump missing', "missing"),
|
('label start:\n jump missing', "missing"),
|
||||||
])
|
])
|
||||||
@@ -362,3 +362,14 @@ def test_upload_rejects_unsupported_engine_without_queueing(tmp_path, monkeypatc
|
|||||||
assert not upload_path(123).exists()
|
assert not upload_path(123).exists()
|
||||||
finally:
|
finally:
|
||||||
get_settings.cache_clear()
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_blocking_names_survives_a_call_of_a_call():
|
||||||
|
from app.converter.compatibility import blocking_names
|
||||||
|
|
||||||
|
data = ScriptData()
|
||||||
|
source = ('init python:\n def helper():\n return getattr(renpy.store, "x", lambda: 1)()\n'
|
||||||
|
'label start:\n $ helper()\n')
|
||||||
|
data.prescan(source)
|
||||||
|
RpyParser(data, "t.rpy").parse(source)
|
||||||
|
assert blocking_names(data) == []
|
||||||
@@ -57,10 +57,10 @@ def test_if_chain_and_menu():
|
|||||||
|
|
||||||
|
|
||||||
def test_unsupported_python_is_reported_not_fatal():
|
def test_unsupported_python_is_reported_not_fatal():
|
||||||
d = parse("init python:\n def f():\n yield 1\n x = 1\nlabel start:\n $ y = x\n")
|
d = parse("init python:\n def f():\n received = yield 1\n x = 1\nlabel start:\n $ y = x\n")
|
||||||
assert d.init_code[0]["op"] == "set" and "f" in d.deferred # reported once something calls it
|
assert d.init_code[0]["op"] == "set" and "f" in d.deferred # reported once something calls it
|
||||||
from app.converter.compatibility import audit_ir
|
from app.converter.compatibility import audit_ir
|
||||||
d = parse("init python:\n def f():\n yield 1\nlabel start:\n $ f()\n")
|
d = parse("init python:\n def f():\n received = yield 1\nlabel start:\n $ f()\n")
|
||||||
audit_ir(d)
|
audit_ir(d)
|
||||||
assert d.blockers
|
assert d.blockers
|
||||||
|
|
||||||
|
|||||||
@@ -235,3 +235,33 @@ def test_init_if_has_live2d_keeps_only_the_else_branch_and_crop_call_glued_to_a_
|
|||||||
def test_static_atl_line_with_a_spaced_crop_tuple():
|
def test_static_atl_line_with_a_spaced_crop_tuple():
|
||||||
data = parse('image plap:\n "base"\n crop (1697,54, 172, 94)\nimage slurp:\n "base"\n crop (1, 2, 3, 4) xanchor 0.5\n')
|
data = parse('image plap:\n "base"\n crop (1697,54, 172, 94)\nimage slurp:\n "base"\n crop (1, 2, 3, 4) xanchor 0.5\n')
|
||||||
assert not data.blockers
|
assert not data.blockers
|
||||||
|
|
||||||
|
|
||||||
|
def test_priority_screen_continue_and_offset_with_computed_halves():
|
||||||
|
data = parse('init -490 screen s(items):\n for item in items:\n if item == 1:\n continue\n text "[item]"\n'
|
||||||
|
'transform lift:\n linear 0.2 offset (0, -LIFT)\nlabel start:\n call screen s([1, 2])\n')
|
||||||
|
assert "s" in data.screens
|
||||||
|
loop = data.screens["s"]["body"][0]
|
||||||
|
assert loop["c"] == "for" and loop["body"][0]["branches"][0]["body"] == [{"c": "continue"}]
|
||||||
|
assert not data.blockers
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_alias_decorated_class_null_image_window_in_init_and_offset_line():
|
||||||
|
from app.converter import pyexpr
|
||||||
|
|
||||||
|
code = pyexpr.parse_stmts("import time as stamp\nfrom renpy.text.text import Text as RenpyText\n"
|
||||||
|
"@renpy.pure\nclass Typing(VariableInputValue):\n pass\n")
|
||||||
|
assert code[0] == {"op": "set", "t": {"k": "name", "id": "stamp"}, "v": {"k": "name", "id": "time"}}
|
||||||
|
assert code[1]["v"] == {"k": "name", "id": "Text"} and code[2]["op"] == "class"
|
||||||
|
data = parse('init 1:\n image blank = Null()\ninit -1:\n window hide\ntransform t:\n offset (0, 0)\n on idle:\n linear 0.2 offset (1, 2)\n')
|
||||||
|
assert data.images["blank"] == {"color": "#00000000"} and not data.blockers
|
||||||
|
|
||||||
|
|
||||||
|
def test_starred_tuple_items_and_pickle_hooks_are_accepted():
|
||||||
|
from app import pgvn
|
||||||
|
from app.converter import pyexpr
|
||||||
|
|
||||||
|
code = pyexpr.parse_stmts("pair = (first, *rest)\nclass Scene:\n def __setstate__(self, state):\n pass\n def run(self):\n return 1\n")
|
||||||
|
pgvn.statements(code)
|
||||||
|
assert code[0]["v"]["v"][1] == {"k": "star", "v": {"k": "name", "id": "rest"}}
|
||||||
|
assert [m["name"] for m in code[1]["body"]] == ["run"]
|
||||||
@@ -246,3 +246,16 @@ def test_dynamic_at_value_and_draggable_box():
|
|||||||
body = data.screens["s"]["body"]
|
body = data.screens["s"]["body"]
|
||||||
assert body[0]["props"]["at"][0]["dyn"]["k"] == "sub"
|
assert body[0]["props"]["at"][0]["dyn"]["k"] == "sub"
|
||||||
assert body[1]["w"] == "drag" and "draggable" in body[1]["props"]
|
assert body[1]["w"] == "drag" and "draggable" in body[1]["props"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_input_widget_values_and_focus_actions_are_accepted():
|
||||||
|
data = parse('screen s():\n default name_value = VariableInputValue("name")\n input:\n id "player_name_input"\n'
|
||||||
|
' value name_value\n length 12\n input:\n default "Bob"\n allow "ab"\n'
|
||||||
|
' textbutton "x" action [name_value.Toggle(), Confirm("sure?", yes=NullAction()), CaptureFocus("tip")]\n'
|
||||||
|
' for i in [1, 2]:\n if i == 1:\n continue\n text "[i]"\n'
|
||||||
|
'label start:\n call screen s\n')
|
||||||
|
audit_ir(data)
|
||||||
|
require_supported_scripts(data)
|
||||||
|
body = data.screens["s"]["body"]
|
||||||
|
assert [n.get("w") for n in body[:3]] == ["input", "input", "textbutton"]
|
||||||
|
assert "default" in body[1]["props"] and "value" in body[0]["props"]
|
||||||
Reference in new issue
Block a user