Refactor CommandTest, inspired by PR #2326 by @davewiththenicehat

This commit is contained in:
Griatch 2021-03-14 16:22:21 +01:00
parent 9c2268d3d1
commit 3e90e0bb14
3 changed files with 184 additions and 51 deletions

3
.gitignore vendored
View file

@ -50,3 +50,6 @@ twistd.bat
# never commit docs/build
docs/build
# For users of Atom
.remote-sync.json

View file

@ -38,6 +38,8 @@
to how `utils.delay` is a shortcut for TaskHandler add.
- Refactor the classic `red_button` example to use `utils.delay/repeat` and modern recommended
code style and paradigms instead of relying on `Scripts` for everything.
- Expand `CommandTest` with ability to check multipler msg-receivers; inspired by PR by
user davewiththenicehat. Also add new doc string.
### Evennia 0.9.5 (2019-2020)

View file

@ -48,24 +48,50 @@ from evennia.prototypes import prototypes as protlib
# set up signal here since we are not starting the server
_RE = re.compile(r"^\+|-+\+|\+-+|--+|\|(?:\s|$)", re.MULTILINE)
_RE_STRIP_EVMENU = re.compile(r"^\+|-+\+|\+-+|--+|\|(?:\s|$)", re.MULTILINE)
# ------------------------------------------------------------
# Command testing
# ------------------------------------------------------------
@patch("evennia.server.portal.portal.LoopingCall", new=MagicMock())
class CommandTest(EvenniaTest):
"""
Tests a command
Tests a Command by running it and comparing what messages it sends with
expected values. This tests without actually spinning up the cmdhandler
for every test, which is more controlled.
Example:
::
from commands.echo import CmdEcho
class MyCommandTest(CommandTest):
def test_echo(self):
'''
Test that the echo command really returns
what you pass into it.
'''
self.call(MyCommand(), "hello world!",
"You hear your echo: 'Hello world!'")
"""
# formatting for .call's error message
_ERROR_FORMAT = """
=========================== Wanted message ===================================
{expected_msg}
=========================== Returned message =================================
{returned_msg}
==============================================================================
""".rstrip()
def call(
self,
cmdobj,
args,
input_args,
msg=None,
cmdset=None,
noansi=True,
@ -77,54 +103,140 @@ class CommandTest(EvenniaTest):
raw_string=None,
):
"""
Test a command by assigning all the needed
properties to cmdobj and running
cmdobj.at_pre_cmd()
cmdobj.parse()
cmdobj.func()
cmdobj.at_post_cmd()
The msgreturn value is compared to eventual
output sent to caller.msg in the game
Test a command by assigning all the needed properties to a cmdobj and
running the sequence. The resulting `.msg` calls will be mocked and
the text= calls to them compared to a expected output.
Args:
cmdobj (Command): The command object to use.
input_args (str): This should be the full input the Command should
see, such as 'look here'. This will become `.args` for the Command
instance to parse.
msg (str or dict, optional): This is the expected return value(s)
returned through `caller.msg(text=...)` calls in the command. If a string, the
receiver is controlled with the `receiver` kwarg (defaults to `caller`).
If this is a `dict`, it is a mapping
`{receiver1: "expected1", receiver2: "expected2",...}` and `receiver` is
ignored. The message(s) are compared with the actual messages returned
to the receiver(s) as the Command runs. Each check uses `.startswith`,
so you can choose to only include the first part of the
returned message if that's enough to verify a correct result. EvMenu
decorations (like borders) are stripped and should not be included. This
should also not include color tags unless `noansi=False`.
If the command returns texts in multiple separate `.msg`-
calls to a receiver, separate these with `|` if `noansi=True`
(default) and `||` if `noansi=False`. If no `msg` is given (`None`),
then no automatic comparison will be done.
cmdset (str, optional): If given, make `.cmdset` available on the Command
instance as it runs. While `.cmdset` is normally available on the
Command instance by default, this is usually only used by
commands that explicitly operates/displays cmdsets, like
`examine`.
noansi (str, optional): By default the color tags of the `msg` is
ignored, this makes them significant. If unset, `msg` must contain
the same color tags as the actual return message.
caller (Object or Account, optional): By default `self.char1` is used as the
command-caller (the `.caller` property on the Command). This allows to
execute with another caller, most commonly an Account.
receiver (Object or Account, optional): This is the object to receive the
return messages we want to test. By default this is the same as `caller`
(which in turn defaults to is `self.char1`). Note that if `msg` is
a `dict`, this is ignored since the receiver is already specified there.
cmdstring (str, optional): Normally this is the Command's `key`.
This allows for tweaking the `.cmdname` property of the
Command`. This isb used for commands with multiple aliases,
where the command explicitly checs which alias was used to
determine its functionality.
obj (str, optional): This sets the `.obj` property of the Command - the
object on which the Command 'sits'. By default this is the same as `caller`.
This can be used for testing on-object Command interactions.
inputs (list, optional): A list of strings to pass to functions that pause to
take input from the user (normally using `@interactive` and
`ret = yield(question)` or `evmenu.get_input`). Each element of the
list will be passed into the command as if the user wrote that at the prompt.
raw_string (str, optional): Normally the `.raw_string` property is set as
a combination of your `key/cmdname` and `input_args`. This allows
direct control of what this is, for example for testing edge cases
or malformed inputs.
Returns:
msg (str): The received message that was sent to the caller.
str or dict: The message sent to `receiver`, or a dict of
`{receiver: "msg", ...}` if multiple are given. This is usually
only used with `msg=None` to do the validation externally.
Raises:
AssertionError: If the returns of `.msg` calls (tested with `.startswith`) does not
match `expected_input`.
Notes:
As part of the tests, all methods of the Command will be called in
the proper order:
- cmdobj.at_pre_cmd()
- cmdobj.parse()
- cmdobj.func()
- cmdobj.at_post_cmd()
"""
# The `self.char1` is created in the `EvenniaTest` base along with
# other helper objects like self.room and self.obj
caller = caller if caller else self.char1
receiver = receiver if receiver else caller
cmdobj.caller = caller
cmdobj.cmdname = cmdstring if cmdstring else cmdobj.key
cmdobj.raw_cmdname = cmdobj.cmdname
cmdobj.cmdstring = cmdobj.cmdname # deprecated
cmdobj.args = args
cmdobj.args = input_args
cmdobj.cmdset = cmdset
cmdobj.session = SESSIONS.session_from_sessid(1)
cmdobj.account = self.account
cmdobj.raw_string = raw_string if raw_string is not None else cmdobj.key + " " + args
cmdobj.raw_string = raw_string if raw_string is not None else cmdobj.key + " " + input_args
cmdobj.obj = obj or (caller if caller else self.char1)
# test
old_msg = receiver.msg
inputs = inputs or []
try:
# set up receivers
receiver_mapping = {}
if isinstance(msg, dict):
# a mapping {receiver: msg, ...}
receiver_mapping = {recv: str(msg).strip() if msg else None
for recv, msg in msg.items()}
else:
# a single expected string and thus a single receiver (defaults to caller)
receiver = receiver if receiver else caller
receiver_mapping[receiver] = str(msg).strip() if msg else None
unmocked_msg_methods = {}
for receiver in receiver_mapping:
# save the old .msg method so we can get it back
# cleanly after the test
unmocked_msg_methods[receiver] = receiver.msg
# replace normal `.msg` with a mock
receiver.msg = Mock()
# Run the methods of the Command. This mimics what happens in the
# cmdhandler. This will have the mocked .msg be called as part of the
# execution. Mocks remembers what was sent to them so we will be able
# to retrieve what was sent later.
try:
if cmdobj.at_pre_cmd():
return
cmdobj.parse()
ret = cmdobj.func()
# handle func's with yield in them (generators)
# handle func's with yield in them (making them generators)
if isinstance(ret, types.GeneratorType):
while True:
try:
inp = inputs.pop() if inputs else None
if inp:
try:
# this mimics a user's reply to a prompt
ret.send(inp)
except TypeError:
next(ret)
ret = ret.send(inp)
else:
# non-input yield, like yield(10). We don't pause
# but fire it immediately.
next(ret)
except StopIteration:
break
@ -135,40 +247,56 @@ class CommandTest(EvenniaTest):
except InterruptCommand:
pass
# clean out evtable sugar. We only operate on text-type
stored_msg = [
args[0] if args and args[0] else kwargs.get("text", utils.to_str(kwargs))
for name, args, kwargs in receiver.msg.mock_calls
]
# Get the first element of a tuple if msg received a tuple instead of a string
stored_msg = [str(smsg[0]) if isinstance(smsg, tuple) else str(smsg) for smsg in stored_msg]
if msg is not None:
msg = str(msg) # to be safe, e.g. `py` command may return ints
# set our separator for returned messages based on parsing ansi or not
msg_sep = "|" if noansi else "||"
# Have to strip ansi for each returned message for the regex to handle it correctly
returned_msg = msg_sep.join(
_RE.sub("", ansi.parse_ansi(mess, strip_ansi=noansi)) for mess in stored_msg
).strip()
msg = msg.strip()
if msg == "" and returned_msg or not returned_msg.startswith(msg):
prt = ""
for ic, char in enumerate(msg):
import re
# At this point the mocked .msg methods on each receiver will have
# stored all calls made to them (that's a basic function of the Mock
# class). We will not extract them and compare to what we expected to
# go to each receiver.
prt += char
returned_msgs = {}
for receiver, expected_msg in receiver_mapping.items():
# get the stored messages from the Mock with Mock.mock_calls.
stored_msg = [
args[0] if args and args[0] else kwargs.get("text", utils.to_str(kwargs))
for name, args, kwargs in receiver.msg.mock_calls
]
# we can return this now, we are done using the mock
receiver.msg = unmocked_msg_methods[receiver]
sep1 = "\n" + "=" * 30 + "Wanted message" + "=" * 34 + "\n"
sep2 = "\n" + "=" * 30 + "Returned message" + "=" * 32 + "\n"
sep3 = "\n" + "=" * 78
retval = sep1 + msg + sep2 + returned_msg + sep3
raise AssertionError(retval)
else:
returned_msg = "\n".join(str(msg) for msg in stored_msg)
returned_msg = ansi.parse_ansi(returned_msg, strip_ansi=noansi).strip()
receiver.msg = old_msg
# Get the first element of a tuple if msg received a tuple instead of a string
stored_msg = [str(smsg[0])
if isinstance(smsg, tuple) else str(smsg) for smsg in stored_msg]
if expected_msg is None:
# no expected_msg; just build the returned_msgs dict
return returned_msg
returned_msg = "\n".join(str(msg) for msg in stored_msg)
returned_msgs[receiver] = ansi.parse_ansi(returned_msg, strip_ansi=noansi).strip()
else:
# compare messages to expected
# set our separator for returned messages based on parsing ansi or not
msg_sep = "|" if noansi else "||"
# We remove Evmenu decorations since that just makes it harder
# to write the comparison string. We also strip ansi before this
# comparison since otherwise it would mess with the regex.
returned_msg = msg_sep.join(
_RE_STRIP_EVMENU.sub(
"", ansi.parse_ansi(mess, strip_ansi=noansi))
for mess in stored_msg).strip()
# this is the actual test
if expected_msg == "" and returned_msg or not returned_msg.startswith(expected_msg):
# failed the test
raise AssertionError(
self._ERROR_FORMAT.format(
expected_msg=expected_msg, returned_msg=returned_msg)
)
# passed!
returned_msgs[receiver] = returned_msg
if len(returned_msgs) == 1:
return list(returned_msgs.values())[0]
return returned_msgs
# ------------------------------------------------------------