Compare commits

...

88 Commits

Author SHA1 Message Date
Gouvernathor 4402611aaa various fixes 2023-05-13 19:40:41 +02:00
Gouvernathor abb7557070 doc tweaks 2023-05-13 19:24:26 +02:00
Gouvernathor bbd8d7c5c8 Deactivate execution of not
it would always end up executing an unready clause
2023-05-13 18:51:59 +02:00
Gouvernathor 6fb6425cf4 Merge branch 'master' into testcase-ex 2023-05-13 18:48:50 +02:00
Gouvernathor acfc5772ee Not sure 2023-03-17 23:28:17 +01:00
Gouvernathor fd55d9f010 Also check if the save was correctly loaded
the re-setting of the value is there to make sure a save is loaded at all, and the value check to make sure it was the correct save that was loaded
2023-03-17 23:27:57 +01:00
Gouvernathor 5e494d18b3 Fix tutorial testcase 2023-03-17 23:24:43 +01:00
Gouvernathor 266c69e71d doc updates 2023-03-17 23:23:07 +01:00
Gouvernathor 2c431f7a08 Fix 19fa815 2023-03-17 23:22:31 +01:00
Gouvernathor 51070c6d0e Make pass a clause instead of a statement
That way we have an or-neutral element (and not pass is the and-neutral element)
2023-03-17 23:21:39 +01:00
Gouvernathor a2232535e8 Set attribute at the correct time
In case of an exception
2023-03-17 23:20:26 +01:00
Gouvernathor 459295f97e Optimize int castings 2023-03-17 19:32:13 +01:00
Gouvernathor 641f5698b7 Simplify test
We don't support subpixel, sure, but this is faster and more "correct" if any absolute finds its way through.
Also there's no need to cast to int if we're doing it afterwards anyway.
2023-03-17 19:18:56 +01:00
Gouvernathor eb3f6a2e66 doc: better explanation 2023-03-17 16:52:05 +01:00
Gouvernathor 19fa815048 Simplify focus finder code 2023-03-17 16:51:46 +01:00
Gouvernathor 8342a66d9b have a pass at the doc page 2023-03-16 23:54:07 +01:00
Gouvernathor 7aa9d66597 Commit small launcher test 2023-03-16 22:11:29 +01:00
Gouvernathor aa9f15a199 Cleanup a bit 2023-03-16 22:10:12 +01:00
Asriel Senna c6cd0fe411 add caveat about renpy.is_in_test 2023-03-16 21:15:22 +01:00
Asriel Senna 11cc6aebf8 add The Question example testcase 2023-03-16 21:15:21 +01:00
Asriel Senna ef98d82679 add not/and/or documentation (and fix line endings) 2023-03-16 21:15:20 +01:00
Asriel Senna e5d16a984b fix example 2023-03-16 21:15:19 +01:00
Asriel Senna 3eba03854b update the readiness at each execution instead of in start 2023-03-16 21:15:18 +01:00
Asriel Senna 692bf4c34b remove short-circuit in readiness check causing inaccurate sub-readiness 2023-03-16 21:15:17 +01:00
Asriel Senna 451c3a7baf make code more compact to find the problem 2023-03-16 21:15:17 +01:00
Asriel Senna 3dec7f6498 first pass at and/or state management 2023-03-16 21:15:16 +01:00
Asriel Senna 29f4c1caee add boolean operations between clauses 2023-03-16 21:15:15 +01:00
Asriel Senna 3543238e5b fix execute methods 2023-03-16 21:15:14 +01:00
Asriel Senna 70ce1aa5c7 simplify these repetititive start methods 2023-03-16 21:15:13 +01:00
Asriel Senna 56ee681250 add the Not, And and Or test clauses 2023-03-16 21:15:13 +01:00
Asriel Senna 0f8b1ade00 update doc 2023-03-16 21:15:12 +01:00
Asriel Senna ca77d6f8d7 add the hide property to python blocks 2023-03-16 21:15:11 +01:00
Asriel Senna 14e19cd9d0 further documentation 2023-03-16 21:15:11 +01:00
Asriel Senna 29979fd828 improve the repr 2023-03-16 21:15:10 +01:00
Asriel Senna 777ff166a8 let me sleep before writing code 2023-03-16 21:15:09 +01:00
Asriel Senna 57abd5bd0f fix super calls 2023-03-16 21:15:08 +01:00
Asriel Senna 6c08d61125 update doc and add things to add later 2023-03-16 21:15:07 +01:00
Asriel Senna 12b070c7c7 create Clause subclass for more readability 2023-03-16 21:15:06 +01:00
Asriel Senna f9995e88fd updates following assert's change of meaning 2023-03-16 21:15:05 +01:00
Asriel Senna a6f2e6b2ce update doc 2023-03-16 21:15:05 +01:00
Asriel Senna 144001b412 add renpy.is_in_test 2023-03-16 21:15:04 +01:00
Asriel Senna 6b585d2227 document that the run clause takes lists 2023-03-16 21:15:03 +01:00
Asriel Senna 322d247bcd rename 2023-03-16 21:15:02 +01:00
Asriel Senna 12cc2d45c9 add to the config list 2023-03-16 21:15:02 +01:00
Asriel Senna 13e8445144 document config.testcase_finally_callbacks 2023-03-16 21:15:01 +01:00
Asriel Senna 5c80ffa509 add config.testcase_finally_callbacks 2023-03-16 21:15:00 +01:00
Asriel Senna 4adaddafae update 2023-03-16 21:13:54 +01:00
Asriel Senna 4517f76396 better document click and patterns 2023-03-16 21:13:53 +01:00
Asriel Senna a85c53c436 make label inexecutive, and assert clause-taking like if 2023-03-16 21:13:53 +01:00
Asriel Senna 3344defb89 raise betterer exception 2023-03-16 21:13:52 +01:00
Asriel Senna 1e24279acc raise better exception 2023-03-16 21:13:51 +01:00
Asriel Senna 42b38f32c6 disable the (useless) always property for patternless click clauses 2023-03-16 21:13:49 +01:00
Asriel Senna 73339577cf fix exit example 2023-03-16 21:13:48 +01:00
Asriel Senna d49c392383 rst fixes 2023-03-16 21:13:47 +01:00
Asriel Senna 9d8155ba17 rephrase documentation 2023-03-16 21:13:45 +01:00
Asriel Senna 4a3b27285b document the testcase statement 2023-03-16 21:13:45 +01:00
Asriel Senna e8888933ea reorder and better document the click/string clause 2023-03-16 21:13:44 +01:00
Asriel Senna b671e01f19 make Patterns raise exceptions when the target is not found 2023-03-16 21:13:43 +01:00
Asriel Senna 84b73d7164 document the focusing algorithm, and use faster min instead of sort 2023-03-16 21:13:43 +01:00
Asriel Senna 1398dc08d0 parameterize the number of trials in find_position 2023-03-16 21:13:42 +01:00
Asriel Senna 98ee23888d remove advance clause 2023-03-16 21:13:41 +01:00
Asriel Senna 70cb354f81 add has_default_focus
Co-Authored-By: Tom Rothamel <pytom@bishoujo.us>
2023-03-16 21:13:40 +01:00
Asriel Senna 479d19bb9b fix wrong offset
Co-Authored-By: Tom Rothamel <pytom@bishoujo.us>
2023-03-16 21:13:39 +01:00
Asriel Senna 15744d14c1 fix rst 2023-03-16 21:13:38 +01:00
Asriel Senna a9f5024399 big advancement on the doc 2023-03-16 21:13:38 +01:00
Asriel Senna 671685ea64 add advance (except for the readiness condition) 2023-03-16 21:13:37 +01:00
Asriel Senna e2f2baca94 document if eval 2023-03-16 21:13:36 +01:00
Asriel Senna 92556b3a66 change doc structure 2023-03-16 21:13:35 +01:00
Asriel Senna 1c26110256 add eval clause 2023-03-16 21:13:34 +01:00
Asriel Senna 75383c00c2 fix pass 2023-03-16 21:13:33 +01:00
Asriel Senna 44d3155a6f more proper reorder 2023-03-16 21:13:32 +01:00
Asriel Senna 64e35b7d49 document the clause concept differently 2023-03-16 21:13:32 +01:00
Asriel Senna 3f23fb10a3 add pass 2023-03-16 21:13:31 +01:00
Asriel Senna 2a54a20a37 doc updates 2023-03-16 21:13:30 +01:00
Asriel Senna eb048b5e03 add exit statement 2023-03-16 21:13:29 +01:00
Asriel Senna fadf4b0e1c raise more accurate exception types 2023-03-16 21:13:26 +01:00
Asriel Senna b627cf66a2 makes label raise an exception instead of creating a silly loop 2023-03-16 21:13:25 +01:00
Asriel Senna e40561a5ae check for both 2023-03-16 21:13:24 +01:00
Asriel Senna ac9c91eeec fix bugs in class attributes 2023-03-16 21:13:23 +01:00
Asriel Senna 1efa5b6178 oh 2023-03-16 21:13:22 +01:00
Asriel Senna 83d48b2428 update doc page 2023-03-16 21:13:21 +01:00
Asriel Senna b1b613a247 update doc page 2023-03-16 21:13:20 +01:00
Asriel Senna 98eba57192 clean stuff 2023-03-16 21:13:19 +01:00
Asriel Senna 36210285ea slotting ast nodes (like in normal-renpy ast) 2023-03-16 21:13:18 +01:00
Asriel Senna 2b2f4a5d1d add doc stub 2023-03-16 21:13:17 +01:00
Asriel Senna 148cd16ae6 Add launcher button to launch tests on the current game 2023-03-16 21:13:17 +01:00
Asriel Senna eba7bf474d use better exception types (probably very little relevance) 2023-03-16 21:13:16 +01:00
Asriel Senna 595a94c44c register the test CLI command correctly 2023-03-16 21:13:15 +01:00
16 changed files with 816 additions and 120 deletions
+9
View File
@@ -196,6 +196,7 @@ screen front_page_project:
textbutton _("Navigate Script") action Jump("navigation")
textbutton _("Check Script (Lint)") action Jump("lint")
textbutton _("Launch tests") action Jump("launch_tests")
if project.current.exists("game/gui.rpy"):
textbutton _("Change/Update GUI") action Jump("change_gui")
@@ -283,3 +284,11 @@ label force_recompile:
project.current.launch([ 'compile' ], wait=True)
jump front_page
label launch_tests:
python hide:
interface.processing(_("Launching tests..."))
project.current.launch([ 'test' ], wait=True)
jump front_page
+24 -2
View File
@@ -1,9 +1,31 @@
init python:
TEST_PROJECTS = u"/tmp/renpy-moé"
import shutil
def finally_callback():
print("finally "*100)
whether = renpy.is_in_test()
define config.end_testcase_callbacks += [finally_callback]
testcase default:
python hide:
renpy.watch("renpy.is_in_test()")
renpy.watch("waitch")
renpy.watch("whether")
waitch = 5
"preferences"
"theme"
"dEfAuLt theme"
"theme"
"dark the"
"return"
# exit
"quit"
init python:
TEST_PROJECTS = "/tmp/renpy-moé"
import shutil
testcase old_default:
call new_project
call translate_project
+2 -2
View File
@@ -665,7 +665,7 @@ class Node(object):
be executed after this one.
"""
raise Exception("Node subclass forgot to define execute.")
raise NotImplementedError("Node subclass forgot to define execute.")
# def early_execute(self):
# """
@@ -719,7 +719,7 @@ class Node(object):
text in the statement.
"""
raise Exception("Not Implemented")
raise NotImplementedError
def analyze(self):
"""
+4
View File
@@ -308,6 +308,10 @@ You may be using a system install of python. Please run {0}.sh,
finally:
if renpy.test.testexecution.node is not None:
for clbk in renpy.config.end_testcase_callbacks:
clbk()
if "RENPY_SHUTDOWN_TRACE" in os.environ:
enable_trace(int(os.environ["RENPY_SHUTDOWN_TRACE"]))
+3
View File
@@ -1377,6 +1377,9 @@ early_developer = False
# A function that returns the autosave prefix.
autosave_prefix_callback = None
# Functions called when a test ends - one way or another
end_testcase_callbacks = [ ]
# Compatibility for c492f2f8e6de57b2740fa5d00566f445124caaba.
at_transform_compare_full_context = False
+24
View File
@@ -4302,6 +4302,17 @@ def add_to_all_stores(name, value):
ns[name] = value
def is_in_test():
"""
:doc: other
Returns True if Ren'py is currently executing a testcase.
This always returns False at init time.
"""
return (renpy.test.testexecution.node is not None)
def get_zorder_list(layer):
"""
:doc: image_func
@@ -4436,6 +4447,19 @@ def set_focus(screen, id, layer="screens"): # @ReservedAssignment
restart_interaction()
def has_default_focus():
"""
:doc: other
Returns true if the game can be advanced by clicking or pressing the
spacebar.
For example, it is True in a dialogue or a pause, and False in places
like the Game Menu.
"""
return any(f.x is None for f in renpy.display.focus.focus_list)
def check_permission(permission):
"""
:doc: android_permission
+193 -77
View File
@@ -42,6 +42,9 @@ class TestSettings(renpy.object.Object):
# How long should we wait for a transition before we proceed?
self.transition_timeout = 5.0
# How many times should we try to find a good spot to place the mouse?
self.focus_trials = 100
_test = TestSettings()
@@ -49,7 +52,7 @@ class Node(object):
"""
An AST node for a test script.
"""
__slots__ = ("filename", "linenumber")
def __init__(self, loc):
self.filename, self.linenumber = loc
@@ -60,6 +63,7 @@ class Node(object):
This is expected to return a state, or None to advance to the next
node.
"""
return True
def execute(self, state, t):
"""
@@ -71,14 +75,12 @@ class Node(object):
`t`
The time since start was called.
"""
return state
def ready(self):
"""
Returns True if this node is ready to execute, or False otherwise.
"""
return True
def report(self):
@@ -86,21 +88,26 @@ class Node(object):
Reports the location of this statement. This should only be called
in the execute method of leaf nodes of the test tree.
"""
renpy.test.testexecution.node_loc = (self.filename, self.linenumber)
class Pattern(Node):
class Clause(Node):
__slots__ = ()
position = None
always = False
def __repr__(self):
return "<{} test clause>".format(type(self).__name__.lower())
class PatternException(ValueError):pass
class Pattern(Clause):
__slots__ = ("pattern", "position", "always")
def __init__(self, loc, pattern=None):
Node.__init__(self, loc)
super(Pattern, self).__init__(loc)
self.pattern = pattern
def start(self):
return True
self.position = None
self.always = False
def execute(self, state, t):
@@ -121,11 +128,10 @@ class Pattern(Node):
else:
x, y = renpy.test.testfocus.find_position(f, position)
if x is None:
if None in (x, y):
if self.pattern:
return state
else:
x, y = renpy.exports.get_mouse_pos()
raise PatternException("The given {!r} pattern was not resolved to a target".format(self.pattern))
x, y = renpy.exports.get_mouse_pos()
return self.perform(x, y, state, t)
@@ -136,16 +142,12 @@ class Pattern(Node):
f = renpy.test.testfocus.find_focus(self.pattern)
if f is not None:
return True
else:
return False
return (f is not None)
def perform(self, x, y, state, t):
return None
class Click(Pattern):
# The number of the button to click.
button = 1
@@ -155,21 +157,18 @@ class Click(Pattern):
class Move(Pattern):
__slots__ = ()
def perform(self, x, y, state, t):
move_mouse(x, y)
return None
class Scroll(Node):
class Scroll(Clause):
__slots__ = "pattern"
def __init__(self, loc, pattern=None):
Node.__init__(self, loc)
super(Scroll, self).__init__(loc)
self.pattern = pattern
def start(self):
return True
def execute(self, state, t):
self.report()
@@ -206,19 +205,16 @@ class Scroll(Node):
return False
class Drag(Node):
class Drag(Clause):
__slots__ = ("points", "pattern", "button", "steps")
def __init__(self, loc, points):
Node.__init__(self, loc)
super(Drag, self).__init__(loc)
self.points = points
self.pattern = None
self.button = 1
self.steps = 10
def start(self):
return True
def execute(self, state, t):
self.report()
@@ -241,7 +237,7 @@ class Drag(Node):
points = [ renpy.test.testfocus.find_position(f, i) for i in points ]
if len(points) < 2:
raise Exception("A drag requires at least two points.")
raise ValueError("A drag requires at least two points.")
interpoints = [ ]
@@ -294,8 +290,8 @@ class Drag(Node):
class Type(Pattern):
interval = .01
__slots__ = "keys"
# interval = .01 # unused
def __init__(self, loc, keys):
Pattern.__init__(self, loc)
@@ -318,10 +314,13 @@ class Type(Pattern):
return state + 1
class Action(Node):
class Action(Clause):
"""
This is for the `run` keyword
"""
__slots__ = "expr"
def __init__(self, loc, expr):
Node.__init__(self, loc)
super(Action, self).__init__(loc)
self.expr = expr
def start(self):
@@ -345,10 +344,10 @@ class Action(Node):
return renpy.display.behavior.is_sensitive(action)
class Pause(Node):
class Pause(Clause):
__slots__ = "expr"
def __init__(self, loc, expr):
Node.__init__(self, loc)
super(Pause, self).__init__(loc)
self.expr = expr
def start(self):
@@ -364,34 +363,149 @@ class Pause(Node):
return None
class Label(Node):
class Label(Clause):
__slots__ = "name"
def __init__(self, loc, name):
Node.__init__(self, loc)
super(Label, self).__init__(loc)
self.name = name
def start(self):
return True
def execute(self, state, t):
if self.name in renpy.test.testexecution.labels:
return None
else:
return state
return None
def ready(self):
return self.name in renpy.test.testexecution.labels
class Eval(Clause):
__slots__ = ("expr", "evaluated")
def __init__(self, loc, expr):
super(Eval, self).__init__(loc)
self.expr = expr
self.evaluated = False
def execute(self, state, t):
if not self.evaluated: # check if this is necessary
self.ready()
return None
def ready(self):
rv = bool(renpy.python.py_eval(self.expr))
self.evaluated = True
return rv
class Pass(Clause):
__slots__ = ()
def execute(self, state, t):
return None
################################################################################
# Boolean proxy clauses
class Not(Clause):
__slots__ = "clause"
def __init__(self, loc, clause):
super(Not, self).__init__(loc)
self.clause = clause
def start(self):
return self.clause.start()
def execute(self, state, t):
# return self.clause.execute(state, t)
return None
def ready(self):
return not self.clause.ready()
class Binary(Clause):
__slots__ = ("left", "right",
"left_ready", "right_ready",
"left_state", "right_state")
def __init__(self, loc, left, right):
super(Binary, self).__init__(loc)
self.left = left
self.right = right
self.left_ready = self.right_ready = None
def start(self):
self.left_state = self.left.start()
self.right_state = self.right.start()
return self.state()
class And(Binary):
__slots__ = ()
def state(self):
if (self.left_state is None) and (self.right_state is None):
return None
return True
def execute(self, state, t):
"""
Executes both if both are ready, otherwise the left one.
"""
self.ready()
if self.left_state is not None:
self.left_state = self.left.execute(self.left_state, t)
if self.left_ready and self.right_ready and (self.right_state is not None):
self.right_state = self.right.execute(self.right_state, t)
return self.state()
def ready(self):
"""
Memorizes the computed values.
Effectively returns self.left.ready() and self.right.ready().
"""
self.left_ready = self.left.ready()
self.right_ready = self.right.ready()
return self.left_ready and self.right_ready
class Or(Binary):
__slots__ = ()
def state(self):
if (self.left_state is None) or (self.right_state is None):
return None
return True
def execute(self, state, t):
"""
Executes the ready one(s), if any, otherwise the right one.
"""
self.ready()
if self.left_ready and (self.left_state is not None):
self.left_state = self.left.execute(self.left_state, t)
if (self.right_ready or not self.left_ready) and (self.right_state is not None):
self.right_state = self.right.execute(self.right_state, t)
return self.state()
def ready(self):
"""
Memorizes the computed values.
Effectively returns self.left.ready() or self.right.ready().
"""
self.left_ready = self.left.ready()
self.right_ready = self.right.ready()
return self.left_ready or self.right_ready
################################################################################
# Non-clause statements.
class Until(Node):
"""
Executes `left` repeatedly until `right` is ready, then executes `right`
once before quitting.
Executes `left` repeatedly until `right` is ready (and unless it already is),
then executes `right` once before quitting.
"""
__slots__ = ("left", "right")
def __init__(self, loc, left, right):
Node.__init__(self, loc)
self.left = left
@@ -403,7 +517,7 @@ class Until(Node):
def execute(self, state, t):
child, child_state, start = state
if self.right.ready() and not (child is self.right):
if self.right.ready() and (child is not self.right):
child = self.right
child_state = None
@@ -431,7 +545,7 @@ class If(Node):
If `condition` is ready, runs the block. Otherwise, goes to the next
statement.
"""
__slots__ = ("condition", "block")
def __init__(self, loc, condition, block):
Node.__init__(self, loc)
@@ -459,10 +573,11 @@ class If(Node):
class Python(Node):
def __init__(self, loc, code):
__slots__ = ("code", "hide")
def __init__(self, loc, code, hide=False):
Node.__init__(self, loc)
self.code = code
self.hide = hide
def start(self):
renpy.test.testexecution.action = self
@@ -478,35 +593,31 @@ class Python(Node):
return None
def __call__(self):
renpy.python.py_exec_bytecode(self.code.bytecode)
renpy.python.py_exec_bytecode(self.code.bytecode, self.hide)
class AssertError(AssertionError):pass
class Assert(Node):
def __init__(self, loc, expr):
__slots__ = "clause"
def __init__(self, loc, clause):
Node.__init__(self, loc)
self.expr = expr
def start(self):
renpy.test.testexecution.action = self
return True
self.clause = clause
def execute(self, state, t):
self.report()
if renpy.test.testexecution.action:
return True
else:
return None
if not self.clause.ready():
raise AssertError("On line {}:{}, assertion of {} failed.".format(self.filename,
self.linenumber,
self.clause))
def __call__(self):
if not renpy.python.py_eval(self.expr):
raise Exception("On line {}:{}, assertion {} failed.".format(self.filename, self.linenumber, self.expr))
return None
class Jump(Node):
__slots__ = "target"
def __init__(self, loc, target):
Node.__init__(self, loc)
@@ -518,7 +629,7 @@ class Jump(Node):
class Call(Node):
__slots__ = "target"
def __init__(self, loc, target):
Node.__init__(self, loc)
@@ -544,7 +655,7 @@ class Call(Node):
# Control structures.
class Block(Node):
__slots__ = "block"
def __init__(self, loc, block):
Node.__init__(self, loc)
self.block = block
@@ -569,3 +680,8 @@ class Block(Node):
i += 1
return i, start, s
class Exit(Node):
__slots__ = ()
def execute(self, state, t):
raise renpy.game.QuitException
+4 -2
View File
@@ -86,7 +86,7 @@ def lookup(name, from_node):
if name in testcases:
return testcases[name]
raise Exception("Testcase {} not found at {}:{}.".format(name, from_node.filename, from_node.linenumber))
raise KeyError("Testcase {} not found at {}:{}.".format(name, from_node.filename, from_node.linenumber))
def execute_node(now, node, state, start):
@@ -162,6 +162,8 @@ def execute():
if node is None:
renpy.test.testmouse.reset()
for clbk in renpy.config.end_testcase_callbacks:
clbk()
return
loc = renpy.exports.get_filename_line()
@@ -196,4 +198,4 @@ def test_command():
return True
renpy.arguments.register_command("test", test_command)
renpy.arguments.register_command("test", test_command, uses_display=True)
+21 -25
View File
@@ -35,13 +35,19 @@ def find_focus(pattern):
If `pattern` is None, returns a random coordinate that will trigger the
default focus.
If `pattern` could not be found, returns None, None.
If `pattern` could not be found, returns None.
"""
if pattern is not None:
pattern = pattern.casefold()
def match(f):
if pattern is None:
if f.x is None:
# one focus, at most, ever branches here
# the main "click to continue" one
# it's the only one, if any, to be retained in the matching list
return "default"
else:
return None
@@ -51,38 +57,34 @@ def find_focus(pattern):
else:
t = f.widget._tts_all()
if pattern.lower() in t.lower():
if pattern in t.casefold():
return t
else:
return None
# A list of alt_text, focus pairs.
matching = [ ]
# {focus : (len(alt), alt)}
matching = {}
for f in renpy.display.focus.focus_list:
alt = match(f)
if alt is not None:
matching.append((alt, f))
if not matching:
return None
matching[f] = (len(alt), alt)
# This gets the matching displayable with the shortest alt text, which
# is likely what we want.
matching.sort(key=lambda a : (len(a[0]), a[0]))
return matching[0][1]
return min(matching, key=matching.get, default=None) # type: ignore
def relative_position(x, posx, width):
if posx is not None:
if isinstance(posx, float):
x = int(posx * (width - 1))
if type(posx) is float:
x = posx * (width - 1)
else:
x = posx
return int(x)
return x
def find_position(f, position):
@@ -105,8 +107,8 @@ def find_position(f, position):
if f is None:
return (
relative_position(x, posx, renpy.config.screen_width),
relative_position(y, posy, renpy.config.screen_height),
int(relative_position(x, posx, renpy.config.screen_width)),
int(relative_position(y, posy, renpy.config.screen_height)),
)
orig_f = f
@@ -119,13 +121,10 @@ def find_position(f, position):
f.w = renpy.config.screen_width
f.h = renpy.config.screen_height
x = relative_position(x, posx, f.w) + f.x
y = relative_position(y, posy, f.h) + f.y
x = int(relative_position(x-f.x, posx, f.w) + f.x)
y = int(relative_position(y-f.y, posy, f.h) + f.y)
for _i in range(100):
x = int(x)
y = int(y)
for _i in range(renpy.test.testast._test.focus_trials):
nf = renpy.display.render.focus_at_point(x, y)
@@ -139,7 +138,4 @@ def find_position(f, position):
x = random.randrange(f.x, f.x + f.w)
y = random.randrange(f.y, f.y + f.h)
else:
print()
raise Exception("Could not locate the displayable.")
raise Exception("Could not locate the displayable.")
+42 -6
View File
@@ -39,7 +39,7 @@ def parse_click(l, loc, target):
elif l.keyword('pos'):
rv.position = l.require(l.simple_expression)
elif l.keyword('always'):
elif (target is not None) and l.keyword('always'):
rv.always = True
else:
@@ -103,8 +103,31 @@ def parse_drag(l, loc):
return rv
def parse_not(l, loc):
if l.keyword("not"):
return testast.Not(loc, parse_not(l, loc))
else:
return parse_clause(l, loc)
def parse_and(l, loc):
rv = parse_not(l, loc)
while l.keyword("and"):
rv = testast.And(loc, rv, parse_not(l, loc))
return rv
def parse_or(l, loc):
rv = parse_and(l, loc)
while l.keyword("or"):
rv = testast.Or(loc, rv, parse_and(l, loc))
return rv
def parse_clause(l, loc):
if l.keyword("run"):
if l.match(r'\('):
rv = parse_or(l, loc)
l.require(r'\)')
return rv
elif l.keyword("run"):
expr = l.require(l.simple_expression)
return testast.Action(loc, expr)
@@ -119,6 +142,11 @@ def parse_clause(l, loc):
name = l.require(l.name)
return testast.Label(loc, name)
elif l.keyword('eval'):
source = l.require(l.simple_expression)
return testast.Eval(loc, source)
elif l.keyword("type"):
name = l.name()
@@ -143,6 +171,9 @@ def parse_clause(l, loc):
pattern = l.require(l.string)
return testast.Scroll(loc, pattern)
elif l.keyword("pass"):
return testast.Pass(loc)
else:
target = l.string()
if target:
@@ -156,14 +187,16 @@ def parse_statement(l, loc):
if l.keyword('python'):
hide = l.keyword('hide')
l.require(':')
l.expect_block("python block")
source = l.python_block()
code = renpy.ast.PyCode(source, loc)
return testast.Python(loc, code)
code = renpy.ast.PyCode(source, loc, 'hide' if hide else 'exec')
return testast.Python(loc, code, hide)
if l.keyword("if"):
l.expect_block("if block")
@@ -186,8 +219,8 @@ def parse_statement(l, loc):
return testast.Python(loc, code)
elif l.keyword('assert'):
source = l.require(l.rest)
return testast.Assert(loc, source)
check = parse_clause(l, loc)
return testast.Assert(loc, check)
elif l.keyword('jump'):
target = l.require(l.name)
@@ -197,6 +230,9 @@ def parse_statement(l, loc):
target = l.require(l.name)
return testast.Call(loc, target)
elif l.keyword('exit'):
return testast.Exit(loc)
rv = parse_clause(l, loc)
if l.keyword("until"):
+8
View File
@@ -1350,6 +1350,14 @@ Rarely or Internally Used
controller GUID (which cand be found in log.txt), and if matched,
prevent the controller from being initialized.
.. var:: config.end_testcase_callbacks = [ ... ]
A list of functions that are called (without any arguments) when
execution of testcases end - in a good way or not. This is intended
to free resources as well as cleaning up anything that may have been
modified for the purpose of the test, such as persistent or save
data.
.. var:: config.exception_handler = None
If not None, this should be a function that takes three arguments:
+1
View File
@@ -84,6 +84,7 @@ To find out more about Ren'Py, please visit the Ren'Py home page:
launcher
developer_tools
director
testcases
.. toctree::
+1
View File
@@ -62,6 +62,7 @@ behavior:
default store, the Python will execute in the store with that
name.
.. _dollar-line:
One-line Python Statement
-------------------------
+461
View File
@@ -0,0 +1,461 @@
=====================
Integrated test suite
=====================
Ren'py allows creators to put tests in their games to make sure that an
alteration to the code didn't break the game.
Testcase statement
==================
The ``testcase`` statement creates a named testcase, it takes a name, a colon,
and a block which contains successive test statements (see below). Testcases are
similar to Ren'py :ref:`labels <label-statement>`, with a few specificities:
- The Testcase statement takes test statements, the Ren'py label statement takes
Ren'py code. Both are mutually excusive.
- There is no testcase equivalent of the return statement: like in a python
block, the removal of indentation and the end of the block closes the testcase.
- There can be no test statement outside of a testcase block, while there can be
Ren'py code oustside labels (in init blocks for example).
::
testcase default:
"Start"
exit
The testcase named ``default`` will be executed first - the other ones may be
called by the default testcase.
Note that when a testcase finishes executing, the game doesn't close itself
(unless the ``exit`` statement is used). Instead, it will just remain where it
ended up in the game, awaiting user action.
When that happens, or if the game quits before that happens (because of an
``exit``, because of an exception, or for another reason), the functions added
to the :var:`config.end_testcase_callbacks` list are called without arguments.
This allows for clean-up code to be executed whatever may happen during the
tests.
.. should an exception during a callback call prevent subsequent callbacks from being called ?
There is no equivalent callback for code being executed before the tests
happen, as such code can simply be put inside a python block at the beginning
of the testcase, or in ``init python`` blocks.
The :func:`renpy.is_in_test` function is helpful to know whether a test is currently
executing or not.
Test statements
===============
The following statements can be written in ``testcase`` blocks.
python blocks and dollar-lines
------------------------------
A :ref:`python block <python-statement>` or a :ref:`dollar-line` can be added
within a testcase. Unlike in normal Ren'py code, the python blocks don't take
the ``in substore`` parameter, but it does take the ``hide`` keyword. They
(both) allow execution of arbitrary python code.
Init code gets executed before the test occurs, so functions and classes defined
in ``init python`` blocks can be called in test python blocks and in test
dollar-lines. For example::
init python in test:
def afunction():
if renpy.is_in_test():
return "test"
return "not test"
testcase default:
$ print(test.afunction()) # ends up in the console
if statement
------------
This statement, like a python ``if`` statement, takes a block.
Unlike the Ren'py or python versions however, it only takes a
:ref:`test clause <test-clauses>`, instead of a general python expression.
The provided clause being ready is the actual condition for the block to execute
or not.
Example::
if label "chapter_five":
exit
A normal python/renpy ``if`` can be replicated using the :ref:`test-eval-clause`\ ::
if eval (persistent.should_advance and
i_should_advance["now"]):
click
.. there is no elif nor else clause
assert statement
----------------
Similarly to a python assert, this statement takes a :ref:`clause <test-clauses>`
and raises an AssertError if the clause is not ready at the time when the assert
statement executes.
A python assert can be replicated with::
assert eval some_function(args)
About python assert statements, see the python documentation
`regarding asserts <https://docs.python.org/reference/simple_stmts.html#the-assert-statement>`__
and
`about boolean evaluation <https://docs.python.org/library/stdtypes.html#truth-value-testing>`__.
.. note::
The regular ``assert`` python statement is not guaranteed to work in Ren'py.
Notably, it was disabled in version 7, and in some older versions.
Therefore, the following may not actually check what it's supposed to check::
$ assert some_function(args)
In some versions of Ren'py or depending on unpredictable conditions,
``some_function`` may not even get called. The assert test statement should
be used instead.
jump statement
--------------
call statement
--------------
.. reminder that there is no return statement in testcases
.. to jump to a renpy label, use the run clause:: run Jump("label_name")
clause statement
----------------
A :ref:`clause <test-clauses>` can be given, just by itself. ::
pause 5
click
until statement
---------------
This statement consists in two :ref:`clauses <test-clauses>`, separated by the
word ``until``.
If and when the clause on the right is ready, it is executed and control is
passed to the next statement. Otherwise, the left clause is executed until the
right clause is ready, and then the right clause is executed.
This is basically an inline while loop. ::
click until eval renpy.get_screen("choice")
"ask her right"
click until "It's an interactive book."
exit statement
--------------
Quits the game without calling the confirmation screen.
Does not save the game when quitting::
if eval need_to_confirm:
run Quit(confirm=True) # asks for confirmation, and autosaves
if eval persistent.quit_test_using_action:
run Quit(confirm=False) # does not ask, but still autosaves
exit # neither asks nor autosaves
.. _test-clauses:
Test clauses
============
Clauses are defined by two things: what it does when they are executed, and
under what circumstances they are ready. They can be part of ``if``, ``assert``
or ``until`` test statements, or they can simply be given on their own (see
above).
.. for each one, say what makes it ready
pass clause
--------------
Does not do anything. It's a no-op, allowing for empty testcases. ::
testcase not_yet_implemented:
pass
It is always ready.
click clause
---------------
Executes a simulated click on the screen. It takes the following optional
properties:
- ``button`` specifies which button of the simulated mouse is to be clicked
with. It takes an integer and defaults to 1. 1 is a left-click, 2 is a
right-click, 3 is a scrollwheel-click, 4 and 5 are additional buttons found on
some mouses. Normally only 1 and 2 trigger any response from Ren'py.
- ``pos`` specifies where to click, as a pair of x/y coordinates. Coordinates
are taken relative to the screen. Floats between 0.0 and 1.0 are supported as
a fraction of the screen size in either dimension. ``absolute`` and other
means of expressing positions are not supported, since you can't move the
mouse to a subpixel-precise position.
.. ``always`` is not documented because useless in the case of the click clause by itself
Click behaves like a :ref:`pattern <test-pattern>`\ -taking clause which would
not be given a pattern: if no ``pos`` is provided, it will look for a neutral
place where a click would not occur on a focusable element.
.. give example for both
This clause is always ready.
The :func:`has_default_focus` function is a helpful accessor to know whether a
game can be advanced by a bare ``click`` clause or not::
click until eval (not renpy.has_default_focus())
string expression clause
------------------------
This clause consists in a simple string, which is interpreted as a
:ref:`pattern <test-pattern>`. It executes by simulating a click on the target
identified by the pattern.
It takes three optional properties:
- ``button`` - same as the click clause
- ``pos`` - same as the click clause, but the position is relative to the
focusable area of the target. If the position is ommitted or is invalid, for example if a
button is 100x100 pixels and the given ``pos`` is (105, 150), then the ``pos``
is ignored and a random position within the target is used instead.
- ``always`` does not take a value. It overrides the readiness of the clause,
making it always ready.
This clause is ready if and when its pattern resolves to a suitable target found
on the screen, or if it is given the ``always`` property. ::
testcase play_the_game:
"start"
run clause
-------------
Runs the provided :doc:`screen-language action <screen_actions>` (or list of
actions).
Ready if and when a button containing the provided action (or list) would be
sensitive. ::
testcase chapter_3:
run Jump("chapter_3")
pause clause
---------------
Pauses test execution for a given number of seconds.
This clause is always ready. ::
pause 5.0
Similar to the :ref:`pause-statement`, but requires a value (there is no
click-to-continue pause in tests).
label clause
---------------
Does not do anything when executed. This clause only exists to be used inside
clause-taking test statements like ``assert``, ``if`` or ``until``.
The label clause is ready if and when the provided label has been passed between
the current test statement and the one just before.
Attention, this means that the following example does not work::
"play chapter 1"
# passing the "chapter_1" label
pause 1
assert label chapter_1
It will not work because no renpy label will have been reached between the
statement containing the label clause and the preceding statement, which in this
case are the assert statement and the pause statement, respectively. The same
happens in the following example::
"play chapter 1"
# passing the "chapter_1" label
assert label chapter_1 # works
assert label chapter_1 # fails
The chapter_1 label is not reached between the first label clause and the second
label clause, therefore the second label clause fails (technically, the clause
is not ready and the assert fails).
In both examples, the assert label statement would have worked if it were placed
on its own, directly after the ``"play chapter 1"`` string expression statement
(or after the comment, which doesn't count as a statement)::
"play chapter 1"
# passing the "chapter_1" label
assert label chapter_1
# all fine
.. warning::
This clause should not be confused with the Ren'py native
:ref:`label <label-statement>` statement it refers to, or with the unrelated
:ref:`screen-language label element <sl-label>`.
drag clause
--------------
..
simulate the mouse dragging something from one place to another
by maintaining click blabla
takes an iterable of points to follow as an itinerary
each point must be given as a pair of x/y coordinates, or None
each occurrence of None will be replaced with a coordinate within the focused area of the screen
(the position of the virtual test mouse if already inside it, or a random position within if not)
needs to be given at least two points
ready if the thing it has been told to type in is found, or if no target has been given
show example of ((None, 10), (None, 100)) being an only-vertical movement downwards
.. warning disambiguation ? probably not necessary
scroll clause
----------------
..
takes a string giving it a pattern
ready when the target (pattern) is found
If the target is a bar, scrolls it down a page. If already at the bottom, returns it to the top.
.. _test-eval-clause:
eval clause
-----------
This clause is ready if and when the provided expression evaluates to a true
value, in a boolean context.
When executed, does not do anything other than evaluating the expression it is
given. This clause exists only to be used inside clause-taking test statements
like ``assert``, ``if`` or ``until``, effectively turning ``assert`` and ``if``
into their non-clause-taking python equivalents::
assert eval (renpy.is_in_test() and
("Ren'py" in renpy.version_string))
.. note::
Differences between a dollar-line and the eval clause :
- A dollar-line executes any python statement, which does not necessarily
have a value - for example ``$ import math`` - while the eval clause
requires an expression, a.k.a something having a value.
- The eval clause provides a value to an ``if`` or ``until`` statement,
while these statements can't take a dollar sign, much less a dollar-line.
..
When the returned value of a function call is to be ignored, both are technically equivalent::
$ print("Test 1")
eval print("Test 2")
This is because functions always return a value (None being a value), unless they raise an exception.
type clause
--------------
.. simulate a key-pressing or the typing of text
..
It is ready if a pattern is not provided,
or if one is provided and a suitable target is found on the screen.
For the clauses taking the ``always`` property, that property overrides the readiness of the clause.
..
warning disambiguation this has nothing to do with the python builtin
move clause
--------------
..
`move (position) [pattern (string)]`
moves the virtual test mouse to the provided position, within the area targeted by the pattern
or, if none is given, within the whole screen
..
It is ready if a pattern is not provided,
or if one is provided and a suitable target is found on the screen.
For the clauses taking the ``always`` property, that property overrides the readiness of the clause.
Boolean clause operations
-------------------------
Test clauses support the ``not``, ``and`` and ``or`` operators: an expression
containing clauses linked with these operators can be provided in lieu of a
single clause. That expression must always be enclosed in parentheses.
The readiness of a boolean clause expression is the computation of the readiness
of the clauses it contains:
- ``(not a)`` is ready if and when ``a`` is not ready
- ``(a and b)`` is ready when both ``a`` and ``b`` are ready
- ``(a or b)`` is ready when either ``a`` or ``b`` is ready.
What happens when boolean clause operations execute is a little more complex.
When executed:
- ``not`` doesn't do anything.
- ``and`` executes both clauses if both are ready, and the left one otherwise.
- ``or`` executes its ready clause(s), if any, and the right one otherwise. (TODO : maybe it should execute the ready clause if only one is, and the right one otherwise)
More information can be found in the python documentation
`regarding these operators <https://docs.python.org/3/reference/expressions.html#boolean-operations>`__
as for why it works that way. The readiness and the behavior of such expressions
when executed can be inferred by replacing each clause in the expression with
its respective readiness, and working out which of the clause is the result of
the operation.
.. _test-pattern:
Patterns
===============
Some clauses take a pattern, which helps positioning the mouse or locating where
a clause will do what it does.
The ``pattern`` property takes a string (except in the case of the string
expression clause, where it is the string itself) which resolves to a target
found on the screen, based on the shortest match among the alt text of focusable
screen elements (typically, buttons). The search is case-insensitive.
If no pattern is given, the virtual test mouse is positioned to the last
previous location where a click happened, or to the specified position, if any.
If that position lies on a focusable element, a random position in the screen
which does not overlap a focusable element is chosen instead.
If a pattern is given, the mouse is positioned to the last previous location
where a click happened, or to the specified position, if any. If that position
does not lie inside the targeted element, a random position within it is chosen
instead. To that end, things like :propref:`focus_mask` are taken into account.
If a pattern is given and if it does not resolve to a target at the time when
the clause using it executes, an exception is raised (terminating the test). To
test whether a given pattern resolves to a target at a given time, the readiness
condition of a string expression clause can be evaluated inside an if statement::
if "ask her right": # if there is a focusable element containing that text on screen
# add a clause using that pattern
+6
View File
@@ -0,0 +1,6 @@
testcase default:
"start"
click until "ask her right away"
click until ("an interactive book" or "video game")
click until eval (not renpy.has_default_focus())
"quit"
+13 -6
View File
@@ -1,5 +1,4 @@

testcase player_experience:
testcase player_experience:
scroll "Bar" until "Player Experience"
click until "Yes."
@@ -198,6 +197,9 @@ testcase translations:
click until label tutorials
init python:
import time
testcase out_of_game:
"Back"
"Back"
@@ -210,6 +212,8 @@ testcase out_of_game:
$ _preferences.afm_time = 1
$ testvar = time.time()
"Auto"
scroll "Bar" until "Player Experience"
"Auto"
@@ -218,8 +222,12 @@ testcase out_of_game:
pause .5
"Save"
pause .5
"Save Slot 1"
"Yes"
("Yes" or pass)
$ testvar = "value not comparable with a number"
"Load"
pause .5
@@ -227,6 +235,8 @@ testcase out_of_game:
"Load Slot 1"
"Yes"
assert eval (time.time() - testvar < 15)
"Prefs"
pause .5
@@ -294,6 +304,3 @@ testcase default:
"That's enough for now."
click until "Quit"
pause .5